2026-09-03 21:30:32 +08:00
|
|
|
|
/**
|
2026-09-21 02:26:39 +08:00
|
|
|
|
* motor/main.js - 电机控制台页面逻辑(ES Module 装配入口)
|
|
|
|
|
|
*
|
|
|
|
|
|
* P1 拆分(R4–R7):本文件只做装配与页面共用业务(解码/发送流控/UI),
|
|
|
|
|
|
* 大块职责外提:
|
|
|
|
|
|
* ./scope.js 示波器
|
|
|
|
|
|
* ./ota.js 电机 OTA + IDB
|
|
|
|
|
|
* ./seq.js 命令序列
|
|
|
|
|
|
* ./conn-poll.js 探测/失败计数/轮询调度
|
|
|
|
|
|
* ./transport*.js 链路 I/O + waitVersion / queryNodeIdParam(R5)
|
|
|
|
|
|
* ../common/ecat-bus.js ECAT bootstrap 单点(R6)
|
|
|
|
|
|
* ../common/toast-stack.js 堆叠 toast + setQuietMode(R7)
|
|
|
|
|
|
* 逻辑行为相对拆分前保持不变;不改 CAN 协议解码与流控语义。
|
2026-09-03 21:30:32 +08:00
|
|
|
|
*/
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
2026-09-04 12:18:06 +08:00
|
|
|
|
电机控制台 — 双传输:WiFi(HTTP) / EtherCAT(过程数据),业务逻辑完全共用
|
|
|
|
|
|
|
|
|
|
|
|
传输由工具栏「链路」下拉框切换(记在 localStorage dcu_motor_transport)。
|
|
|
|
|
|
两种传输的差异全部收敛在 js/motor/transport-*.js 里,本文件只经
|
|
|
|
|
|
state.tp.* 调用抽象接口 —— 解码器、节点列表、STATUS 面板、波形、
|
|
|
|
|
|
命令序列、以及全部流控策略(互斥/退避/冷却/latest-wins)两边共用。
|
|
|
|
|
|
|
|
|
|
|
|
WiFi 传输(原有,共用 OTA 主页设备 IP localStorage dcu_esp_ip):
|
|
|
|
|
|
GET /motor/state?bin=1 refreshIntervalMs 驱动(WiFi 默认 250ms,50~5000 可调)
|
|
|
|
|
|
POST /can/send_batch 发送入口(CMD / CONFIG / VERSION / OTA)
|
|
|
|
|
|
GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS
|
|
|
|
|
|
GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测
|
2026-09-05 03:56:52 +08:00
|
|
|
|
EtherCAT 传输(经本地页面服务器 /ecat/api/* 代理到 tools/ecat 的 ecat_native.exe):
|
2026-09-04 12:18:06 +08:00
|
|
|
|
POST /api/motor/state 读后台 PDO 循环发布的快照(不碰总线,默认 50ms 读一次)
|
|
|
|
|
|
POST /api/motor/send 多帧塞进同一 PDO 周期的批量区(同一 seq 沿发出)
|
|
|
|
|
|
POST /api/motor/rx 消费批量接收区(CONFIG/版本/节点ID 应答走这里)
|
|
|
|
|
|
刷新速度按链路各存一份(state.refreshByTp / REFRESH_DEFAULTS):两条链路的
|
|
|
|
|
|
往返代价差一个数量级,切链路时自动换成该链路自己的值,互不污染。
|
|
|
|
|
|
两者的差异(每通道 TEC/REC 缺失、CONFIG 需前端累积、无 OTA 链路)
|
|
|
|
|
|
见 transport-ecat.js 头部注释。
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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
|
2026-09-12 20:10:47 +08:00
|
|
|
|
(扩展格式 DLC4/2:选择器 0x0000=节点ID · 0x0100/0x0101=STATUS/CONFIG 刷新周期 ms)
|
2026-09-03 21:30:32 +08:00
|
|
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
2026-09-16 12:12:24 +08:00
|
|
|
|
DEFAULT_ESP_IP, saveDeviceIp
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} from "../common/base-url.js";
|
2026-09-21 02:26:39 +08:00
|
|
|
|
import {
|
|
|
|
|
|
escapeHtml, flashBusyNow,
|
|
|
|
|
|
setTextIfChanged, setClassIfChanged, setStyleIfChanged,
|
|
|
|
|
|
toggleClsIfChanged as toggleClsIfChangedUtil, setQuietMode,
|
|
|
|
|
|
} from "../common/utils.js";
|
|
|
|
|
|
import { createToastStack } from "../common/toast-stack.js?v=20260924a";
|
2026-09-04 12:18:06 +08:00
|
|
|
|
import {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
|
2026-09-21 02:26:39 +08:00
|
|
|
|
} from "./transport.js?v=20260924a";
|
|
|
|
|
|
import { deviceLink } from "../common/device-link.js?v=20260924a";
|
2026-09-04 12:18:06 +08:00
|
|
|
|
import {
|
|
|
|
|
|
REFRESH_DEFAULTS, REFRESH_MAX_MS,
|
|
|
|
|
|
defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor,
|
|
|
|
|
|
isUnthrottled
|
|
|
|
|
|
} from "./refresh-rate.js";
|
2026-09-21 02:26:39 +08:00
|
|
|
|
import { createScopeSys } from "./scope.js?v=20260924a";
|
|
|
|
|
|
import { createSeqSys } from "./seq.js?v=20260924a";
|
|
|
|
|
|
import { createOtaSys } from "./ota.js?v=20260924a";
|
|
|
|
|
|
import { createConnPoll } from "./conn-poll.js?v=20260924a";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
/* ─── 控制模式定义 ─── */
|
|
|
|
|
|
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: "开环速度控制(备用通道)",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/* ─── 全局状态 ─── */
|
|
|
|
|
|
const LS_MOTOR_UI = "dcu_motor_ui_v2";
|
|
|
|
|
|
const state = {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
connected: false,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
connecting: false,
|
|
|
|
|
|
ip: DEFAULT_ESP_IP,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
tp: null,
|
|
|
|
|
|
tpId: "wifi",
|
|
|
|
|
|
probeErr: null,
|
2026-09-21 02:26:39 +08:00
|
|
|
|
probeErrFull: null,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
ch: 0,
|
2026-09-21 02:26:39 +08:00
|
|
|
|
chEnabled: [true, true, true],
|
|
|
|
|
|
chOnline: [0, 0, 0],
|
|
|
|
|
|
selectedNode: null,
|
|
|
|
|
|
motors: [],
|
|
|
|
|
|
bootActive: false,
|
|
|
|
|
|
slaveAl: null,
|
|
|
|
|
|
slaveOp: false,
|
|
|
|
|
|
decoded: new Map(),
|
|
|
|
|
|
decodedConfig: new Map(),
|
|
|
|
|
|
versions: new Map(),
|
2026-09-03 21:30:32 +08:00
|
|
|
|
refreshIntervalMs: 250,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
refreshByTp: defaultRefreshMap(),
|
2026-09-21 02:26:39 +08:00
|
|
|
|
lastSentMode: new Map(),
|
|
|
|
|
|
lastSentTargets: new Map(),
|
|
|
|
|
|
sendInFlight: false,
|
|
|
|
|
|
pendingCmd: null,
|
|
|
|
|
|
busCooldownUntil: 0,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
txCount: 0,
|
|
|
|
|
|
poll: { tick: null, probe: null, status: null, feedback: null },
|
2026-09-21 02:26:39 +08:00
|
|
|
|
pollGen: 0,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
tickInFlight: false,
|
2026-09-21 02:26:39 +08:00
|
|
|
|
tickStuck: 0,
|
|
|
|
|
|
versionWait: null,
|
|
|
|
|
|
feedbackInFlight: false,
|
|
|
|
|
|
statusInFlight: false,
|
|
|
|
|
|
failCount: 0,
|
|
|
|
|
|
probing: false,
|
|
|
|
|
|
probeGen: 0,
|
|
|
|
|
|
lastConnToastAt: 0,
|
|
|
|
|
|
offlineSince: 0,
|
|
|
|
|
|
tickN: 0,
|
|
|
|
|
|
recvTs: [],
|
|
|
|
|
|
statusTickN: 0,
|
|
|
|
|
|
phaseDebug: { lastSlowReq: null, slowStats: {} },
|
2026-09-03 21:30:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* 跨模块 late 绑定:连接/OTA 回调需要 stopSeq 等,模块创建后由 main 回填 */
|
|
|
|
|
|
const late = {};
|
|
|
|
|
|
|
|
|
|
|
|
/* 控制台诊断入口(不删) */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
window.motorDbg = {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
state,
|
|
|
|
|
|
get tickWorker() { return late.connSys ? late.connSys.getTickWorker() : null; },
|
|
|
|
|
|
get tickUseFallback() { return late.connSys ? late.connSys.getTickUseFallback() : false; },
|
|
|
|
|
|
get transport() { return state.tp && state.tp.id; },
|
2026-09-04 12:18:06 +08:00
|
|
|
|
get transportDebug() { return state.tp && state.tp.debug ? state.tp.debug() : null; },
|
2026-09-03 21:30:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* R7:堆叠 toast(#toastContainer),不再内联实现 */
|
|
|
|
|
|
const toast = createToastStack("#toastContainer");
|
|
|
|
|
|
|
|
|
|
|
|
const TICK_INTERVAL_MS = 250;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const MIN_CMD_PERIOD_MS = 80;
|
|
|
|
|
|
|
|
|
|
|
|
/* ─── DOM refs ─── */
|
|
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
|
|
|
const nodeListEl = $("#nodeList");
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const FIXED_MOTOR_NODES = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
function getSharedDeviceIp() { return deviceLink.getIp(); }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 输入持久化 ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const PERSIST_FIELDS = [
|
2026-09-20 22:58:00 +08:00
|
|
|
|
"modeSelect", "inVoltage", "inVelocity", "inAngle", "inCurrent",
|
2026-09-03 21:30:32 +08:00
|
|
|
|
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
|
2026-09-12 20:10:47 +08:00
|
|
|
|
"nodeIdInput", "periodStatusInput", "periodConfigInput",
|
2026-09-03 21:30:32 +08:00
|
|
|
|
];
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const UI_STATE_PATH_LABEL = "data/motor_ui_state.json";
|
|
|
|
|
|
|
|
|
|
|
|
function collectUiSnapshot() {
|
|
|
|
|
|
const o = { savedAt: Date.now(), v: 2 };
|
|
|
|
|
|
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;
|
|
|
|
|
|
o.refreshByTp = Object.assign({}, state.refreshByTp);
|
|
|
|
|
|
o.tpId = state.tpId;
|
|
|
|
|
|
try {
|
|
|
|
|
|
o.transport = localStorage.getItem(LS_TRANSPORT) || state.tpId;
|
|
|
|
|
|
o.ecat = {
|
|
|
|
|
|
adapter: localStorage.getItem("dcu_ecat_adapter") || null,
|
|
|
|
|
|
period: localStorage.getItem("dcu_ecat_period_ms") || null,
|
|
|
|
|
|
spin: localStorage.getItem("dcu_ecat_spin") || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
o.transport = state.tpId;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (seq.steps.length > 0 || seq.loops !== 0) {
|
|
|
|
|
|
o.seq = { loops: seq.loops, steps: seq.steps };
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
return o;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyUiSnapshot(o) {
|
|
|
|
|
|
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;
|
|
|
|
|
|
if (o.seq && Array.isArray(o.seq.steps)) {
|
|
|
|
|
|
seq.steps = o.seq.steps
|
|
|
|
|
|
.filter((s) => s && typeof s === "object")
|
|
|
|
|
|
.map(normStep);
|
|
|
|
|
|
seq.loops = clampNum(parseInt(o.seq.loops, 10) || 0, 0, 9999);
|
|
|
|
|
|
}
|
|
|
|
|
|
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.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (o.transport) localStorage.setItem(LS_TRANSPORT, String(o.transport));
|
|
|
|
|
|
if (o.ecat && typeof o.ecat === "object") {
|
|
|
|
|
|
if (o.ecat.adapter) localStorage.setItem("dcu_ecat_adapter", String(o.ecat.adapter));
|
|
|
|
|
|
if (o.ecat.period != null && o.ecat.period !== "") {
|
|
|
|
|
|
localStorage.setItem("dcu_ecat_period_ms", String(o.ecat.period));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (o.ecat.spin === "0" || o.ecat.spin === "1") {
|
|
|
|
|
|
localStorage.setItem("dcu_ecat_spin", String(o.ecat.spin));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
syncCmdCommittedFromDom();
|
|
|
|
|
|
const chEl = document.getElementById("chSelect");
|
|
|
|
|
|
if (chEl && chEl.value !== "" && chEl.value != null) {
|
|
|
|
|
|
const ch = parseInt(chEl.value, 10);
|
|
|
|
|
|
if (Number.isFinite(ch) && ch >= 0 && ch <= 2) state.ch = ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
function saveUiState() {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const o = collectUiSnapshot();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o));
|
|
|
|
|
|
} catch (_) {}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
scheduleDiskSave(o);
|
|
|
|
|
|
updateUiSaveHint("saved");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadUiState() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = localStorage.getItem(LS_MOTOR_UI);
|
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
|
const o = JSON.parse(raw);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
applyUiSnapshot(o);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
let _diskTimer = null;
|
|
|
|
|
|
let _diskPayload = null;
|
|
|
|
|
|
let _diskInFlight = false;
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleDiskSave(snapshot) {
|
|
|
|
|
|
_diskPayload = snapshot || collectUiSnapshot();
|
|
|
|
|
|
if (_diskTimer) clearTimeout(_diskTimer);
|
|
|
|
|
|
_diskTimer = setTimeout(flushDiskSave, 600);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function flushDiskSave() {
|
|
|
|
|
|
if (_diskTimer) { clearTimeout(_diskTimer); _diskTimer = null; }
|
|
|
|
|
|
const o = _diskPayload || collectUiSnapshot();
|
|
|
|
|
|
_diskPayload = null;
|
|
|
|
|
|
const body = JSON.stringify(o);
|
|
|
|
|
|
_diskInFlight = true;
|
|
|
|
|
|
fetch("/api/motor/ui-state", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body,
|
|
|
|
|
|
keepalive: true,
|
|
|
|
|
|
}).then((r) => r.json()).then((j) => {
|
|
|
|
|
|
_diskInFlight = false;
|
|
|
|
|
|
if (j && j.ok) updateUiSaveHint("disk", j.path || UI_STATE_PATH_LABEL);
|
|
|
|
|
|
else updateUiSaveHint("disk-err");
|
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
_diskInFlight = false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const blob = new Blob([body], { type: "application/json" });
|
|
|
|
|
|
if (navigator.sendBeacon && navigator.sendBeacon("/api/motor/ui-state", blob)) {
|
|
|
|
|
|
updateUiSaveHint("disk", UI_STATE_PATH_LABEL);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
updateUiSaveHint("disk-err");
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function flushUiOnHide() {
|
|
|
|
|
|
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
|
|
|
|
|
const o = collectUiSnapshot();
|
|
|
|
|
|
try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o)); } catch (_) {}
|
|
|
|
|
|
const body = JSON.stringify(o);
|
|
|
|
|
|
try {
|
|
|
|
|
|
fetch("/api/motor/ui-state", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body,
|
|
|
|
|
|
keepalive: true,
|
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
navigator.sendBeacon("/api/motor/ui-state",
|
|
|
|
|
|
new Blob([body], { type: "application/json" }));
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
navigator.sendBeacon("/api/motor/ui-state",
|
|
|
|
|
|
new Blob([body], { type: "application/json" }));
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener("pagehide", flushUiOnHide);
|
|
|
|
|
|
window.addEventListener("beforeunload", flushUiOnHide);
|
|
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
|
|
|
|
if (document.visibilityState === "hidden") flushUiOnHide();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function hydrateUiFromDisk() {
|
|
|
|
|
|
let disk = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch("/api/motor/ui-state", { cache: "no-store" });
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
const j = await r.json();
|
|
|
|
|
|
disk = (j && j.ok && j.state) ? j.state : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) { return; }
|
|
|
|
|
|
if (!disk || typeof disk !== "object") {
|
|
|
|
|
|
updateUiSaveHint("no-disk");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let local = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
local = JSON.parse(localStorage.getItem(LS_MOTOR_UI) || "null");
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
const diskAt = Number(disk.savedAt) || 0;
|
|
|
|
|
|
const localAt = local ? (Number(local.savedAt) || 0) : 0;
|
|
|
|
|
|
if (local && localAt > diskAt) {
|
|
|
|
|
|
updateUiSaveHint("disk", UI_STATE_PATH_LABEL);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
applyUiSnapshot(disk);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(disk)); } catch (_) {}
|
|
|
|
|
|
applyModeVisibility();
|
|
|
|
|
|
renderSeqList();
|
|
|
|
|
|
if (typeof renderNodeList === "function") renderNodeList();
|
|
|
|
|
|
updateNodeLabels();
|
|
|
|
|
|
state.ch = clampNum(parseInt(($("#chSelect") || {}).value, 10) || 0, 0, 2);
|
|
|
|
|
|
const chEl = $("#chSelect");
|
|
|
|
|
|
if (chEl) chEl.value = String(state.ch);
|
|
|
|
|
|
applyTransportRefresh();
|
|
|
|
|
|
updateChSelectOptions();
|
|
|
|
|
|
if (typeof refreshChannelViews === "function") refreshChannelViews();
|
|
|
|
|
|
updateUiSaveHint("disk", UI_STATE_PATH_LABEL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateUiSaveHint(mode, path) {
|
|
|
|
|
|
const el = $("#uiSaveHint");
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const p = path || UI_STATE_PATH_LABEL;
|
|
|
|
|
|
if (mode === "saved") {
|
|
|
|
|
|
el.textContent = "配置保存中…";
|
|
|
|
|
|
el.title = "同时写 localStorage 与 " + p;
|
|
|
|
|
|
} else if (mode === "disk") {
|
|
|
|
|
|
el.textContent = "已存 " + p;
|
|
|
|
|
|
el.title = "配置已写入电脑文件,关页/重启后会自动恢复";
|
|
|
|
|
|
} else if (mode === "disk-err") {
|
|
|
|
|
|
el.textContent = "落盘失败(仅 localStorage)";
|
|
|
|
|
|
el.title = "本地页面服务可能未运行;配置仍存在浏览器里,但清缓存会丢";
|
|
|
|
|
|
} else if (mode === "no-disk") {
|
|
|
|
|
|
el.textContent = "暂无磁盘备份";
|
|
|
|
|
|
el.title = "改动任意配置或关页后会生成 " + p;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exportUiState() {
|
|
|
|
|
|
const o = collectUiSnapshot();
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(o, null, 2)], { type: "application/json" });
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
const ts = new Date();
|
|
|
|
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
|
|
|
|
const name = `motor_ui_${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`;
|
|
|
|
|
|
a.href = URL.createObjectURL(blob);
|
|
|
|
|
|
a.download = name;
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
|
|
|
|
|
saveUiState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function importUiStateFile(file) {
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
const rd = new FileReader();
|
|
|
|
|
|
rd.onload = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const o = JSON.parse(String(rd.result || ""));
|
|
|
|
|
|
if (!o || typeof o !== "object") throw new Error("不是配置对象");
|
|
|
|
|
|
applyUiSnapshot(o);
|
|
|
|
|
|
applyModeVisibility();
|
|
|
|
|
|
renderSeqList();
|
|
|
|
|
|
applyTransportRefresh();
|
|
|
|
|
|
saveUiState();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
toast("配置已导入并保存", "ok");
|
2026-09-20 22:58:00 +08:00
|
|
|
|
} catch (e) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
toast("导入失败: " + (e && e.message), "err");
|
2026-09-20 22:58:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
rd.readAsText(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const loops = $("#seqLoops");
|
|
|
|
|
|
if (loops) {
|
|
|
|
|
|
loops.addEventListener("change", () => {
|
|
|
|
|
|
seq.loops = clampNum(parseInt(loops.value, 10) || 0, 0, 9999);
|
|
|
|
|
|
saveUiState();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const exp = $("#uiExportBtn");
|
|
|
|
|
|
if (exp) exp.addEventListener("click", exportUiState);
|
|
|
|
|
|
const impBtn = $("#uiImportBtn");
|
|
|
|
|
|
const impInput = $("#uiImportInput");
|
|
|
|
|
|
if (impBtn && impInput) {
|
|
|
|
|
|
impBtn.addEventListener("click", () => impInput.click());
|
|
|
|
|
|
impInput.addEventListener("change", () => {
|
|
|
|
|
|
importUiStateFile(impInput.files && impInput.files[0]);
|
|
|
|
|
|
impInput.value = "";
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
let _saveTimer = null;
|
|
|
|
|
|
function debounceSaveUi() {
|
|
|
|
|
|
if (_saveTimer) clearTimeout(_saveTimer);
|
|
|
|
|
|
_saveTimer = setTimeout(saveUiState, 300);
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function setSharedDeviceIp(ip) {
|
|
|
|
|
|
if (!ip || !String(ip).trim()) return;
|
|
|
|
|
|
const v = String(ip).trim();
|
|
|
|
|
|
state.ip = v;
|
2026-09-16 12:12:24 +08:00
|
|
|
|
try { deviceLink.setIp(v); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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)); }
|
|
|
|
|
|
|
|
|
|
|
|
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ID_STATUS = (n) => canId(n, 0x0100);
|
|
|
|
|
|
const ID_CMD = (n) => canId(n, 0x0200);
|
|
|
|
|
|
const ID_CMD_CONFIG = (n) => canId(n, 0x0301);
|
|
|
|
|
|
const ID_GET_VERSION_REQ = (n) => canId(n, 0x00FF);
|
|
|
|
|
|
const ID_SET_NODE_ID = (n) => canId(n, 0x0400);
|
|
|
|
|
|
const ID_GET_NODE_ID = (n) => canId(n, 0x0401);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ─── 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); };
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── STATUS / CONFIG 解码 ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function decodeStatus(bytes) {
|
|
|
|
|
|
if (!bytes || bytes.length < 32) return null;
|
|
|
|
|
|
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
2026-09-07 12:19:26 +08:00
|
|
|
|
const copy = new Uint8Array(36);
|
|
|
|
|
|
copy.set(u8.subarray(0, Math.min(36, u8.length)));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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),
|
2026-09-21 02:26:39 +08:00
|
|
|
|
motStatus: (b10 & 0x01) ? 1 : 0,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
magStatus: (b10 & 0x02) ? 1 : 0,
|
|
|
|
|
|
thermalStatus: (b10 & 0x04) ? 1 : 0,
|
|
|
|
|
|
eepromStatus: (b10 >> 3) & 0x07,
|
2026-09-21 02:26:39 +08:00
|
|
|
|
powerVoltage: dv.getUint16(11, true) * 0.001,
|
|
|
|
|
|
rtAngle: f32(13),
|
|
|
|
|
|
rtVelocity: f32(17),
|
|
|
|
|
|
rtCurrent: f32(21),
|
|
|
|
|
|
testFloat: f32(25),
|
|
|
|
|
|
testU16: dv.getUint16(29, true),
|
|
|
|
|
|
cpuUsage: copy[31],
|
|
|
|
|
|
initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function decodeConfig(hexStr) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const b = hexToBytes(hexStr);
|
|
|
|
|
|
if (!b || b.length < 16) return null;
|
|
|
|
|
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const f32 = (off) => {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (off + 4 > b.length) return 0;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const v = dv.getFloat32(off, true);
|
|
|
|
|
|
return Number.isFinite(v) ? v : 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
return {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
targetMode: b[0],
|
|
|
|
|
|
targetVoltage: f32(4),
|
|
|
|
|
|
targetCurrent: f32(8),
|
|
|
|
|
|
targetVelocity: f32(12),
|
|
|
|
|
|
targetAngle: f32(16),
|
2026-09-03 21:30:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function faultShortParts(d) {
|
|
|
|
|
|
if (!d) return [];
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const p = [];
|
|
|
|
|
|
if (d.motStatus) p.push("电机故障");
|
|
|
|
|
|
if (d.magStatus) p.push("磁编码");
|
|
|
|
|
|
if (d.thermalStatus) p.push("过温");
|
|
|
|
|
|
if (d.eepromStatus) p.push("EEPROM");
|
|
|
|
|
|
return p;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
function faultShortLabel(d) { return faultShortParts(d).join(" · "); }
|
|
|
|
|
|
function faultCountOf(d) { return faultShortParts(d).length; }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── CMD 编码 / 提交制 ─── */
|
|
|
|
|
|
function syncCmdCommittedFromDom() { /* 由 initCmdCommit 注册的实现覆盖/配合 */ }
|
|
|
|
|
|
|
|
|
|
|
|
const _cmdCommit = { vals: {} };
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function initCmdCommit() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ids = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"];
|
|
|
|
|
|
const readOne = (id) => {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
return el ? el.value : "";
|
|
|
|
|
|
};
|
|
|
|
|
|
const commit = () => {
|
|
|
|
|
|
for (const id of ids) _cmdCommit.vals[id] = readOne(id);
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const id of ids) {
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (!el) continue;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
el.addEventListener("change", commit);
|
|
|
|
|
|
}
|
|
|
|
|
|
commit();
|
|
|
|
|
|
}
|
|
|
|
|
|
function syncCmdCommittedFromDomImpl() {
|
|
|
|
|
|
const ids = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"];
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (el) _cmdCommit.vals[id] = el.value;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
syncCmdCommittedFromDom = syncCmdCommittedFromDomImpl;
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function cmdVal(id) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (_cmdCommit.vals[id] != null && _cmdCommit.vals[id] !== "") return _cmdCommit.vals[id];
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
return el ? el.value : "";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildCmdBytes(values) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const bytes = new Uint8Array(32);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const mode = parseInt(values.mode, 10) || 0;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
bytes[0] = mode & 0xFF;
|
|
|
|
|
|
const dv = new DataView(bytes.buffer);
|
|
|
|
|
|
const f32 = (off, v) => {
|
|
|
|
|
|
const n = parseFloat(v);
|
|
|
|
|
|
dv.setFloat32(off, Number.isFinite(n) ? n : 0, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
const u16 = (off, v) => {
|
|
|
|
|
|
const n = parseInt(v, 10);
|
|
|
|
|
|
dv.setUint16(off, Number.isFinite(n) ? n : 0, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
u16(4, values.voltage);
|
|
|
|
|
|
f32(8, values.current);
|
|
|
|
|
|
f32(12, values.velocity);
|
|
|
|
|
|
f32(16, values.angle);
|
|
|
|
|
|
f32(20, values.pidCurP);
|
|
|
|
|
|
f32(24, values.pidVelP);
|
|
|
|
|
|
f32(28, values.pidAngP);
|
|
|
|
|
|
return bytes;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
function buildFormCmdBytes(opts) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
const mode = opts.mode != null ? opts.mode : ($("#modeSelect") ? $("#modeSelect").value : "0");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
return buildCmdBytes({
|
2026-09-21 02:26:39 +08:00
|
|
|
|
mode,
|
|
|
|
|
|
voltage: opts.voltage != null ? opts.voltage : cmdVal("inVoltage"),
|
|
|
|
|
|
current: opts.current != null ? opts.current : cmdVal("inCurrent"),
|
|
|
|
|
|
velocity: opts.velocity != null ? opts.velocity : cmdVal("inVelocity"),
|
|
|
|
|
|
angle: opts.angle != null ? opts.angle : cmdVal("inAngle"),
|
2026-09-03 21:30:32 +08:00
|
|
|
|
pidCurP: cmdVal("pidCurP"),
|
|
|
|
|
|
pidVelP: cmdVal("pidVelP"),
|
|
|
|
|
|
pidAngP: cmdVal("pidAngP"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function bytesToHexStr(bytes) {
|
|
|
|
|
|
let s = "";
|
|
|
|
|
|
for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]);
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 发送核心(流控语义保持不变) ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const GD32_ERR_DESC = {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
0xE2: "发送冷却",
|
|
|
|
|
|
0xE3: "总线错误",
|
|
|
|
|
|
0xE4: "邮箱忙",
|
2026-09-03 21:30:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function describeResult(result) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!result) return "无结果";
|
|
|
|
|
|
if (result.status === "ok") return "ok";
|
|
|
|
|
|
return result.desc || result.status || "error";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendBatch(frames, opts) {
|
|
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
if (!state.connected && !opts.allowOffline) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!opts.silent) toast("设备未连接", "err");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
return { ok: false, desc: "设备未连接", networkError: true };
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (state.sendInFlight) {
|
|
|
|
|
|
if (frames.length === 1) {
|
|
|
|
|
|
state.pendingCmd = frames[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: false, busy: true, desc: "发送通道忙" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (nowMs() < state.busCooldownUntil) {
|
|
|
|
|
|
return { ok: false, busy: true, desc: "总线冷却中" };
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
state.sendInFlight = true;
|
|
|
|
|
|
try {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const results = await state.tp.sendFrames(frames, { ch: state.ch });
|
|
|
|
|
|
const first = results && results[0];
|
|
|
|
|
|
const ok = !!(first && first.status === "ok");
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
state.txCount += frames.length;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const gerr = first && (first.gd32_err || first.code);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (gerr === 0xE2 || gerr === 0xE3) {
|
|
|
|
|
|
state.busCooldownUntil = nowMs() + 3000;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!opts.silent) {
|
|
|
|
|
|
toast("总线异常 (" + (GD32_ERR_DESC[gerr] || gerr) + ") — 停止发送,冷却 3s", "err");
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok,
|
|
|
|
|
|
busy: !!(first && (first.code === 0xE4 || first.status === "busy")),
|
|
|
|
|
|
busFault: gerr === 0xE2 || gerr === 0xE3,
|
|
|
|
|
|
networkError: false,
|
|
|
|
|
|
desc: describeResult(first),
|
|
|
|
|
|
results,
|
|
|
|
|
|
};
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} catch (e) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
networkError: true,
|
|
|
|
|
|
desc: (e && e.message) || "网络错误",
|
|
|
|
|
|
};
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
state.sendInFlight = false;
|
|
|
|
|
|
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) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return sendBatch([{ id, dlc, data: dataHex }], opts || {});
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendOneFrameWhenFree(id, dlc, dataHex, opts) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
opts = opts || {};
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const res = await sendOneFrame(id, dlc, dataHex, opts);
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (res.busy && opts.retry !== false) {
|
|
|
|
|
|
await sleep(50);
|
|
|
|
|
|
return sendOneFrame(id, dlc, dataHex, Object.assign({}, opts, { retried: true }));
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendBurst(actions, opts) {
|
|
|
|
|
|
opts = opts || {};
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!state.connected) {
|
2026-09-03 21:30:32 +08:00
|
|
|
|
return { ok: false, desc: "设备未连接", networkError: true };
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (state.sendInFlight) {
|
|
|
|
|
|
return { ok: false, busy: true, desc: "发送通道忙" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (nowMs() < state.busCooldownUntil) {
|
|
|
|
|
|
return { ok: false, busy: true, desc: "总线冷却中" };
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
state.sendInFlight = true;
|
|
|
|
|
|
try {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const frames = actions.map((a) => {
|
|
|
|
|
|
const bytes = buildFormCmdBytes(a);
|
|
|
|
|
|
return { ch: a.ch, node: a.node, data: bytesToHexStr(bytes) };
|
|
|
|
|
|
});
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const results = await state.tp.sendBurst(frames, { suffix: 0x0200, dlc: 32 });
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const first = results && results[0];
|
|
|
|
|
|
const ok = !!(first && first.status === "ok");
|
|
|
|
|
|
if (ok) state.txCount += frames.length;
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok,
|
|
|
|
|
|
busy: !!(first && (first.code === 0xE4 || first.status === "busy")),
|
|
|
|
|
|
networkError: false,
|
|
|
|
|
|
desc: describeResult(first),
|
|
|
|
|
|
results,
|
|
|
|
|
|
};
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} catch (e) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return { ok: false, networkError: true, desc: (e && e.message) || "网络错误" };
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
state.sendInFlight = false;
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 慢请求统计 / 瞬时错误(TRANSPORT_CTX 注入用) ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function recordSlowReq(path, durMs, errMsg) {
|
|
|
|
|
|
if (durMs <= 200) return;
|
|
|
|
|
|
const s = state.phaseDebug.slowStats;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const key = path.split("?")[0];
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) {
|
|
|
|
|
|
e.transient = true;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 传输层装配 ─── */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const TRANSPORT_CTX = {
|
|
|
|
|
|
getBaseUrl: () => getBaseUrl(),
|
|
|
|
|
|
recordSlow: (path, ms, err) => recordSlowReq(path, ms, err),
|
|
|
|
|
|
isTransient: (e) => isTransientError(e),
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* R5:参数查询/设置发送入口(流控仍留在 main) */
|
|
|
|
|
|
sendFrame: (id, dlc, dataHex, opts) => sendOneFrameWhenFree(id, dlc, dataHex, opts),
|
|
|
|
|
|
getCh: () => state.ch,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function applyTransportRefresh() {
|
|
|
|
|
|
const v = pickRefresh(state.refreshByTp, state.tpId);
|
|
|
|
|
|
const changed = state.refreshIntervalMs !== v;
|
|
|
|
|
|
state.refreshByTp[state.tpId] = v;
|
|
|
|
|
|
state.refreshIntervalMs = v;
|
|
|
|
|
|
const el = $("#refreshRate");
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
const lo = minRefreshFor(state.tpId);
|
|
|
|
|
|
el.min = String(lo);
|
2026-09-21 02:26:39 +08:00
|
|
|
|
el.step = isUnthrottled(lo) ? "1" : "50";
|
2026-09-04 12:18:06 +08:00
|
|
|
|
el.value = v;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!changed) return v;
|
|
|
|
|
|
updateNodeLabels();
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (state.poll.tick) {
|
|
|
|
|
|
clearTimeout(state.poll.tick);
|
|
|
|
|
|
state.poll.tick = null;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (state.connected && !state.probing && late.connSys) {
|
|
|
|
|
|
late.connSys.setTickNextAt(0);
|
|
|
|
|
|
late.connSys.schedulePollLoop(state.pollGen, 0);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return v;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function useTransport(id) {
|
|
|
|
|
|
state.tpId = id;
|
|
|
|
|
|
state.tp = createTransport(id, TRANSPORT_CTX);
|
|
|
|
|
|
return state.tp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function switchTransport(id) {
|
|
|
|
|
|
if (id === state.tpId) return;
|
|
|
|
|
|
const old = state.tp;
|
|
|
|
|
|
stopRepeat(true);
|
|
|
|
|
|
stopSeq("切换链路,命令序列已停止", true);
|
|
|
|
|
|
stopDataPolling();
|
|
|
|
|
|
stopProbe();
|
|
|
|
|
|
state.connected = false;
|
|
|
|
|
|
state.connecting = false;
|
|
|
|
|
|
state.failCount = 0;
|
|
|
|
|
|
if (old) { try { await old.teardown(); } catch (_) {} }
|
|
|
|
|
|
useTransport(id);
|
|
|
|
|
|
saveTransportId(id);
|
|
|
|
|
|
applyTransportUi();
|
|
|
|
|
|
toast("已切换链路: " + state.tp.label + " · 刷新 " + state.refreshIntervalMs + " ms", "ok");
|
|
|
|
|
|
connectShared();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyTransportUi() {
|
|
|
|
|
|
const tp = state.tp;
|
|
|
|
|
|
const sel = $("#transportSelect");
|
|
|
|
|
|
if (sel && sel.value !== state.tpId) sel.value = state.tpId;
|
|
|
|
|
|
const ad = $("#ecatAdapter");
|
|
|
|
|
|
if (ad) {
|
|
|
|
|
|
const show = !!tp.getAdapter;
|
|
|
|
|
|
ad.style.display = show ? "" : "none";
|
|
|
|
|
|
if (show) ad.value = tp.getAdapter();
|
|
|
|
|
|
}
|
|
|
|
|
|
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
|
|
|
|
|
|
if (otaBtn) {
|
|
|
|
|
|
const off = !tp.supportsOta;
|
|
|
|
|
|
otaBtn.disabled = off;
|
|
|
|
|
|
otaBtn.classList.toggle("tab-disabled", off);
|
|
|
|
|
|
otaBtn.title = off
|
|
|
|
|
|
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
|
|
|
|
|
|
: "";
|
|
|
|
|
|
if (off && otaBtn.classList.contains("active")) {
|
|
|
|
|
|
const cfg = document.querySelector('.tab-btn[data-tab="config"]');
|
|
|
|
|
|
if (cfg) cfg.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
applyTransportRefresh();
|
|
|
|
|
|
const rr = document.getElementById("refreshRate");
|
|
|
|
|
|
if (rr) {
|
|
|
|
|
|
const other = state.tpId === "ecat" ? "wifi" : "ecat";
|
|
|
|
|
|
const otherName = other === "wifi" ? "WiFi" : "EtherCAT";
|
|
|
|
|
|
const shared = "两条链路各记一份刷新速度,切链路自动换(" + otherName +
|
|
|
|
|
|
" 当前记着 " + (state.refreshByTp[other] || REFRESH_DEFAULTS[other]) + " ms)。";
|
|
|
|
|
|
rr.parentElement.title = tp.supportsPeriodSync
|
|
|
|
|
|
? "电机状态刷新间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS +
|
|
|
|
|
|
")。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致。" + shared
|
|
|
|
|
|
: "界面读取快照的间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS +
|
|
|
|
|
|
")。EtherCAT 的 PDO 总线周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),不受此值影响。" +
|
|
|
|
|
|
"填 1 表示不限速(实测约 125Hz,受每拍一次 HTTP 往返限制),真实频率看 STATUS 面板右上角。" + shared;
|
|
|
|
|
|
}
|
|
|
|
|
|
updateConnUi();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* 连接接口代理(conn-poll 创建后 late 生效;声明保持提升) */
|
|
|
|
|
|
function updateConnUi() { if (late.connSys) late.connSys.updateConnUi(); }
|
|
|
|
|
|
function connectShared() { if (late.connSys) late.connSys.connectShared(); }
|
|
|
|
|
|
function stopDataPolling() { if (late.connSys) late.connSys.stopDataPolling(); }
|
|
|
|
|
|
function startPolling() { if (late.connSys) late.connSys.startPolling(); }
|
|
|
|
|
|
function stopProbe() { if (late.connSys) late.connSys.stopProbe(); }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── /motor/state 处理与 UI ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function motorKey(m) { return m.ch + ":" + m.node; }
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const UI_PAINT_MIN_MS = 33;
|
|
|
|
|
|
let uiPaintPending = false;
|
|
|
|
|
|
let lastUiPaintAt = 0;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
let lastUiSig = "";
|
2026-09-20 22:58:00 +08:00
|
|
|
|
let lastConnSig = "";
|
|
|
|
|
|
const _decodedReuse = new Map();
|
|
|
|
|
|
const _decodedConfigReuse = new Map();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const _statusRefs = {};
|
2026-09-20 22:58:00 +08:00
|
|
|
|
|
|
|
|
|
|
function statusEl(id) {
|
|
|
|
|
|
let el = _statusRefs[id];
|
|
|
|
|
|
if (el === undefined) {
|
|
|
|
|
|
el = document.getElementById(id);
|
|
|
|
|
|
_statusRefs[id] = el;
|
|
|
|
|
|
}
|
|
|
|
|
|
return el;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
function setClsIfChanged(el, cls) { setClassIfChanged(el, cls); }
|
|
|
|
|
|
function toggleClsIfChanged(el, cls, on) { toggleClsIfChangedUtil(el, cls, on); }
|
2026-09-20 22:58:00 +08:00
|
|
|
|
|
|
|
|
|
|
function paintMotorUi(force) {
|
|
|
|
|
|
const now = nowMs();
|
|
|
|
|
|
if (!force && now - lastUiPaintAt < UI_PAINT_MIN_MS) return false;
|
|
|
|
|
|
lastUiPaintAt = now;
|
|
|
|
|
|
refreshChannelViews();
|
|
|
|
|
|
updateNodeLabels();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleUiPaint() {
|
|
|
|
|
|
if (uiPaintPending) return;
|
|
|
|
|
|
uiPaintPending = true;
|
|
|
|
|
|
const run = () => {
|
|
|
|
|
|
const wait = UI_PAINT_MIN_MS - (nowMs() - lastUiPaintAt);
|
|
|
|
|
|
if (wait > 0) { setTimeout(run, wait); return; }
|
|
|
|
|
|
uiPaintPending = false;
|
|
|
|
|
|
paintMotorUi(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
requestAnimationFrame(run);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const RECV_HZ_WINDOW = 20;
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function onMotorState(data) {
|
|
|
|
|
|
const motors = (data && data.motors) ? data.motors : [];
|
|
|
|
|
|
state.motors = motors;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
state.bootActive = !!(data && data.boot_active);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const ts = state.recvTs;
|
|
|
|
|
|
ts.push(Date.now());
|
|
|
|
|
|
if (ts.length > RECV_HZ_WINDOW * 2) ts.splice(0, ts.length - RECV_HZ_WINDOW);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const decoded = _decodedReuse;
|
|
|
|
|
|
const decodedConfig = _decodedConfigReuse;
|
|
|
|
|
|
decoded.clear();
|
|
|
|
|
|
decodedConfig.clear();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
for (const m of motors) {
|
|
|
|
|
|
const bytes = hexToBytes(m.data);
|
|
|
|
|
|
const d = decodeStatus(bytes);
|
|
|
|
|
|
if (d) decoded.set(motorKey(m), d);
|
|
|
|
|
|
if (m.config_data) {
|
|
|
|
|
|
const cfg = decodeConfig(m.config_data);
|
|
|
|
|
|
if (cfg) decodedConfig.set(motorKey(m), cfg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
state.decoded = decoded;
|
|
|
|
|
|
state.decodedConfig = decodedConfig;
|
|
|
|
|
|
const chOnline = [0, 0, 0];
|
|
|
|
|
|
for (const m of motors) {
|
|
|
|
|
|
if (m.online && m.node >= 1 && m.node <= 9 && m.ch >= 0 && m.ch <= 2) chOnline[m.ch]++;
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const chSig = chOnline.join(",") + "|" + state.chEnabled.join(",") + "|" + (state.tickStuck || 0);
|
|
|
|
|
|
const chChanged = chSig !== lastUiSig;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
state.chOnline = chOnline;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (chChanged) {
|
|
|
|
|
|
lastUiSig = chSig;
|
|
|
|
|
|
updateChSelectOptions();
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
let autoSelected = false;
|
|
|
|
|
|
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
|
|
|
|
|
|
const online = motors.find((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9)
|
|
|
|
|
|
|| motors.find((m) => m.online && m.node >= 1 && m.node <= 9);
|
|
|
|
|
|
state.selectedNode = online ? online.node : 1;
|
|
|
|
|
|
autoSelected = true;
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const bootOn = !!state.bootActive;
|
|
|
|
|
|
const bootEl = statusEl("bootBadge");
|
|
|
|
|
|
if (bootEl && bootEl._on !== bootOn) {
|
|
|
|
|
|
bootEl._on = bootOn;
|
|
|
|
|
|
bootEl.classList.toggle("show", bootOn);
|
|
|
|
|
|
bootEl.style.display = bootOn ? "flex" : "none";
|
|
|
|
|
|
bootEl.setAttribute("aria-hidden", bootOn ? "false" : "true");
|
|
|
|
|
|
const sub = statusEl("bootBadgeSub");
|
|
|
|
|
|
if (sub) sub.textContent = bootOn ? "0x103 心跳" : "0x103";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const onlineCnt = chOnline[state.ch] || 0;
|
|
|
|
|
|
const connSig = [state.connected, state.connecting, state.probing, state.tpId,
|
|
|
|
|
|
state.slaveAl, state.slaveOp, onlineCnt, state.ch, state.probeErr].join("|");
|
|
|
|
|
|
if (connSig !== lastConnSig) {
|
|
|
|
|
|
lastConnSig = connSig;
|
|
|
|
|
|
updateConnUi();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (autoSelected) {
|
|
|
|
|
|
restoreVersionUi(state.selectedNode);
|
|
|
|
|
|
paintMotorUi(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
scheduleUiPaint();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const _nodeCards = new Map();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
function getDisplayMotors() {
|
|
|
|
|
|
const motors = state.motors || [];
|
|
|
|
|
|
return FIXED_MOTOR_NODES.map((node) => {
|
|
|
|
|
|
const entries = motors.filter((m) => m && m.node === node);
|
|
|
|
|
|
const cur = entries.find((m) => m.ch === state.ch);
|
|
|
|
|
|
if (cur && cur.online) return cur;
|
|
|
|
|
|
const others = entries.filter((m) => m.online && m.ch !== state.ch);
|
|
|
|
|
|
if (others.length) {
|
|
|
|
|
|
const chs = others.map((o) => o.ch).sort().join("/");
|
|
|
|
|
|
return { node, ch: others[0].ch, online: false, otherCh: chs, dlc: 0, rx_count: 0, seq: 0, data: "" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cur) return cur;
|
|
|
|
|
|
return { node, ch: state.ch || 0, online: false, dlc: 0, rx_count: 0, seq: 0, data: "" };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refreshChannelViews() {
|
|
|
|
|
|
renderNodeList();
|
|
|
|
|
|
renderStatusPanel();
|
|
|
|
|
|
const onlineCount = (state.motors || [])
|
|
|
|
|
|
.filter((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9).length;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const summary = `${onlineCount}/9 在线 · CH${state.ch}`;
|
|
|
|
|
|
const sumEl = statusEl("nodeSummary") || $("#nodeSummary");
|
|
|
|
|
|
if (sumEl && sumEl.textContent !== summary) sumEl.textContent = summary;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 03:02:14 +08:00
|
|
|
|
function modeLabelOf(m) {
|
|
|
|
|
|
const cfg = state.decodedConfig.get(motorKey(m));
|
|
|
|
|
|
const mode = cfg ? cfg.targetMode : state.lastSentMode.get(m.node);
|
|
|
|
|
|
if (mode == null) return "--";
|
|
|
|
|
|
const def = MODES[mode];
|
|
|
|
|
|
return def ? def.zh : ("模式" + mode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function renderNodeList() {
|
|
|
|
|
|
const motors = getDisplayMotors();
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const m of motors) {
|
|
|
|
|
|
const ref = _nodeCards.get(m.node);
|
|
|
|
|
|
if (!ref) continue;
|
|
|
|
|
|
const d = m.otherCh != null ? null : state.decoded.get(motorKey(m));
|
|
|
|
|
|
const faultCount = faultCountOf(d);
|
|
|
|
|
|
const faultLabel = faultShortLabel(d);
|
|
|
|
|
|
const isSel = m.node === state.selectedNode;
|
|
|
|
|
|
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.otherCh != null) ref.card.title = `节点在 CH${m.otherCh} 在线,切换顶部通道查看`;
|
|
|
|
|
|
else if (!m.online) ref.card.title = "离线也可选中刷写";
|
|
|
|
|
|
else ref.card.title = "在线";
|
|
|
|
|
|
const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "");
|
|
|
|
|
|
const ledFull = "led " + ledCls;
|
|
|
|
|
|
if (ref.led.className !== ledFull) ref.led.className = ledFull;
|
2026-09-10 03:02:14 +08:00
|
|
|
|
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel;
|
|
|
|
|
|
const modeCls = m.otherCh != null ? "nc-mode mode-other-ch"
|
|
|
|
|
|
: (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode"));
|
|
|
|
|
|
if (ref.modeEl.className !== modeCls) ref.modeEl.className = modeCls;
|
|
|
|
|
|
const stTxt = !m.online ? "--" : (faultLabel || "OK");
|
|
|
|
|
|
if (ref.stTxt.textContent !== stTxt) ref.stTxt.textContent = stTxt;
|
|
|
|
|
|
if (ref.stTxt) ref.stTxt.title = faultLabel ? ("故障: " + faultLabel) : (m.online ? "正常" : "");
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (d && m.online && m.otherCh == null) {
|
|
|
|
|
|
if (ref.angleVal) setTextIfChanged(ref.angleVal, d.rtAngle.toFixed(1));
|
|
|
|
|
|
if (ref.velVal) setTextIfChanged(ref.velVal, d.rtVelocity.toFixed(0));
|
|
|
|
|
|
if (ref.curVal) setTextIfChanged(ref.curVal, d.rtCurrent.toFixed(0));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (ref.angleVal) setTextIfChanged(ref.angleVal, "--");
|
|
|
|
|
|
if (ref.velVal) setTextIfChanged(ref.velVal, "--");
|
|
|
|
|
|
if (ref.curVal) setTextIfChanged(ref.curVal, "--");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (ref.faultsEl) setTextIfChanged(ref.faultsEl, faultLabel || "");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildNodeCardHtml(m) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const d = m.online ? state.decoded.get(motorKey(m)) : null;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "");
|
|
|
|
|
|
return `<div class="${cls}" data-node="${m.node}" role="option" aria-selected="${isSel}">
|
|
|
|
|
|
<div class="nc-head">
|
|
|
|
|
|
<span class="led ${ledCls}"></span>
|
|
|
|
|
|
<span class="nc-node">N${m.node}</span>
|
|
|
|
|
|
<span class="nc-mode ${m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""))}">${m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m))}</span>
|
2026-09-03 21:30:32 +08:00
|
|
|
|
</div>
|
2026-09-21 02:26:39 +08:00
|
|
|
|
<div class="nc-status-text">${!m.online ? "--" : (faultLabel || "OK")}</div>
|
|
|
|
|
|
<div class="nc-reading"><span class="k">角度</span><span class="val">${d ? d.rtAngle.toFixed(1) : "--"}</span></div>
|
|
|
|
|
|
<div class="nc-reading"><span class="k">速度</span><span class="val">${d ? d.rtVelocity.toFixed(0) : "--"}</span></div>
|
|
|
|
|
|
<div class="nc-reading"><span class="k">电流</span><span class="val">${d ? d.rtCurrent.toFixed(0) : "--"}</span></div>
|
|
|
|
|
|
<div class="nc-faults">${faultLabel || ""}</div>
|
2026-09-03 21:30:32 +08:00
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
nodeListEl.addEventListener("click", (e) => {
|
|
|
|
|
|
const card = e.target.closest(".node-card");
|
|
|
|
|
|
if (!card) return;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const n = parseInt(card.dataset.node, 10);
|
|
|
|
|
|
if (!Number.isFinite(n)) return;
|
|
|
|
|
|
state.selectedNode = n;
|
|
|
|
|
|
if (scopeSys) scopeSys.reset();
|
|
|
|
|
|
renderNodeList();
|
|
|
|
|
|
updateNodeLabels();
|
|
|
|
|
|
restoreVersionUi(n);
|
|
|
|
|
|
renderStatusPanel();
|
|
|
|
|
|
saveUiState();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-12 20:10:47 +08:00
|
|
|
|
function hzText(hz) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
|
|
|
|
|
return hz >= 10 ? hz.toFixed(0) + " Hz" : hz.toFixed(1) + " Hz";
|
2026-09-12 20:10:47 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function statusHzText() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ts = state.recvTs || [];
|
|
|
|
|
|
if (ts.length < 2) return "--";
|
|
|
|
|
|
const iv = ts[ts.length - 1] - ts[Math.max(0, ts.length - RECV_HZ_WINDOW)];
|
|
|
|
|
|
if (!(iv > 0)) return "--";
|
|
|
|
|
|
return hzText((ts.length - 1) * 1000 / Math.max(iv, 1));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function updateNodeLabels() {
|
|
|
|
|
|
const n = state.selectedNode;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const el = $("#nodeTitle");
|
|
|
|
|
|
if (el) el.textContent = n != null ? `节点 N${n}` : "未选择节点";
|
|
|
|
|
|
const hzEl = statusEl("statusHz") || $("#statusHz");
|
|
|
|
|
|
if (hzEl) hzEl.textContent = statusHzText();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── STATUS 面板 ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function flashEl(el) {
|
|
|
|
|
|
if (!el) return;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
el.classList.remove("flash");
|
|
|
|
|
|
void el.offsetWidth;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
el.classList.add("flash");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderStatusPanel() {
|
|
|
|
|
|
const n = state.selectedNode;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const motor = n != null
|
|
|
|
|
|
? state.motors.find((m) => m.node === n && m.ch === state.ch)
|
|
|
|
|
|
|| state.motors.find((m) => m.node === n)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const d = motor ? state.decoded.get(motorKey(motor)) : null;
|
|
|
|
|
|
const cfg = motor ? state.decodedConfig.get(motorKey(motor)) : null;
|
|
|
|
|
|
const offlineLayer = $("#statusOffline");
|
|
|
|
|
|
const panelLayer = $("#statusPanel");
|
|
|
|
|
|
if (!state.connected || !motor || !motor.online || !d) {
|
|
|
|
|
|
if (offlineLayer) offlineLayer.style.display = "";
|
|
|
|
|
|
if (panelLayer) panelLayer.style.display = "none";
|
|
|
|
|
|
if (offlineLayer) {
|
|
|
|
|
|
const chs = (state.motors || []).filter((m) => m.node === n && m.online)
|
|
|
|
|
|
.map((m) => "CH" + m.ch).join("/");
|
|
|
|
|
|
if (!state.connected) {
|
|
|
|
|
|
setTextIfChanged($("#offlineTitle"), "设备未连接");
|
|
|
|
|
|
setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接");
|
|
|
|
|
|
} else if (chs && state.chEnabled[state.ch] === false) {
|
|
|
|
|
|
setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`);
|
|
|
|
|
|
setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`);
|
|
|
|
|
|
} else if (!motor) {
|
|
|
|
|
|
setTextIfChanged($("#offlineTitle"), `节点 ${n} 离线(仍可刷写)`);
|
|
|
|
|
|
setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`);
|
|
|
|
|
|
setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响");
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (offlineLayer) offlineLayer.style.display = "none";
|
|
|
|
|
|
if (panelLayer) panelLayer.style.display = "";
|
2026-09-20 22:58:00 +08:00
|
|
|
|
setTextIfChanged(statusEl("mcuTemp"), d.mcuTemp.toFixed(2) + " °C");
|
|
|
|
|
|
setTextIfChanged(statusEl("motTemp"), d.motTemp.toFixed(2) + " °C");
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const mcuBar = statusEl("mcuTempBar"), motBar = statusEl("motTempBar");
|
|
|
|
|
|
if (mcuBar) mcuBar.style.width = Math.min(100, Math.max(0, d.mcuTemp)) + "%";
|
|
|
|
|
|
if (motBar) motBar.style.width = Math.min(100, Math.max(0, d.motTemp)) + "%";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (cfg) {
|
2026-09-07 12:19:26 +08:00
|
|
|
|
setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1));
|
|
|
|
|
|
setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0));
|
|
|
|
|
|
setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} else {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
setTextIfChanged($("#tgtAngle"), "--");
|
|
|
|
|
|
setTextIfChanged($("#tgtVelocity"), "--");
|
|
|
|
|
|
setTextIfChanged($("#tgtCurrent"), "--");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
setTextIfChanged(statusEl("valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`);
|
|
|
|
|
|
setTextIfChanged(statusEl("valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`);
|
|
|
|
|
|
setTextIfChanged(statusEl("valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`);
|
|
|
|
|
|
setTextIfChanged(statusEl("pwrVolt"), d.powerVoltage.toFixed(3) + " V");
|
|
|
|
|
|
setTextIfChanged(statusEl("testFloat"), d.testFloat.toFixed(3));
|
|
|
|
|
|
setTextIfChanged(statusEl("testU16"), String(d.testU16));
|
|
|
|
|
|
setTextIfChanged(statusEl("cpuPct"), Math.round(d.cpuUsage) + "%");
|
|
|
|
|
|
if (motor) setTextIfChanged(statusEl("cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`);
|
|
|
|
|
|
const flags = [
|
|
|
|
|
|
["flagMot", d.motStatus, "电机故障", "正常"],
|
|
|
|
|
|
["flagMag", d.magStatus, "磁编码故障", "正常"],
|
|
|
|
|
|
["flagThermal", d.thermalStatus, "过温", "正常"],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const [id, active, faultText, okText] of flags) {
|
|
|
|
|
|
const el = statusEl(id);
|
|
|
|
|
|
if (!el) continue;
|
|
|
|
|
|
setTextIfChanged(el, active ? faultText : okText);
|
2026-09-07 12:19:26 +08:00
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function busStateText(bs) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const map = { 0: "OK", 1: "BUSOFF", 2: "PASSIVE", 3: "WARN", 8: "OK" };
|
|
|
|
|
|
return map[bs] != null ? map[bs] : ("0x" + Number(bs || 0).toString(16));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateTickStuckUi() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const stuck = state.tickStuck > 0;
|
|
|
|
|
|
const el = $("#chSelect");
|
|
|
|
|
|
if (el) el.classList.toggle("tick-stuck", stuck);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateChSelectOptions() {
|
|
|
|
|
|
const sel = $("#chSelect");
|
|
|
|
|
|
if (!sel) return;
|
|
|
|
|
|
for (let i = 0; i < sel.options.length; i++) {
|
|
|
|
|
|
const opt = sel.options[i];
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ch = parseInt(opt.value, 10);
|
|
|
|
|
|
const en = state.chEnabled[ch] !== false;
|
|
|
|
|
|
const cnt = state.chOnline[ch] || 0;
|
|
|
|
|
|
const base = "CH" + ch;
|
2026-09-07 12:19:26 +08:00
|
|
|
|
setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onFeedback(data) {
|
|
|
|
|
|
const channels = (data && data.channels) || [];
|
2026-09-21 02:26:39 +08:00
|
|
|
|
for (const c of channels) {
|
|
|
|
|
|
if (!c || c.ch == null) continue;
|
|
|
|
|
|
state.chEnabled[c.ch] = c.enabled !== false;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
updateChSelectOptions();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const ch = channels[state.ch];
|
|
|
|
|
|
if (!ch) return;
|
|
|
|
|
|
setTextIfChanged($("#sbRx"), "RX " + (ch.rx != null ? ch.rx : "--"));
|
|
|
|
|
|
setTextIfChanged($("#sbTx"), "TX " + (ch.tx != null ? ch.tx : "--"));
|
|
|
|
|
|
setTextIfChanged($("#sbErr"), "ERR " + (ch.err != null ? ch.err : "--"));
|
|
|
|
|
|
setTextIfChanged($("#sbTec"), "TEC " + (ch.tec != null ? ch.tec : "--"));
|
|
|
|
|
|
setTextIfChanged($("#sbRec"), "REC " + (ch.rec != null ? ch.rec : "--"));
|
|
|
|
|
|
setTextIfChanged($("#sbBus"), "BUS " + (ch.bus_state != null ? busStateText(ch.bus_state) : "--"));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 控制模式联动 / CMD ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function applyModeVisibility() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const mode = parseInt(($("#modeSelect") || {}).value, 10) || 0;
|
|
|
|
|
|
const def = MODES[mode];
|
|
|
|
|
|
const targets = def ? def.targets : [];
|
|
|
|
|
|
for (const id of ["f_angle", "f_velocity", "f_current", "f_voltage"]) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (!el) continue;
|
|
|
|
|
|
const key = id.replace("f_", "");
|
|
|
|
|
|
const show = key === "voltage" ? true : targets.includes(key);
|
|
|
|
|
|
el.style.display = show ? "" : "none";
|
|
|
|
|
|
}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
updateQuickHint();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (!requireNode()) return;
|
|
|
|
|
|
if (inBusCooldown()) return;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!state.connected) { if (!silent) toast("设备未连接", "err"); return; }
|
|
|
|
|
|
if (!state.chEnabled[state.ch]) { if (!silent) toast("CH" + state.ch + " 已禁用(GD32 固件未启用此通道)", "err"); return; }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const node = state.selectedNode;
|
|
|
|
|
|
const mode = parseInt($("#modeSelect").value, 10) || 0;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const bytes = buildFormCmdBytes({ mode });
|
|
|
|
|
|
const id = ID_CMD(node);
|
|
|
|
|
|
const res = await sendOneFrameWhenFree(id, 32, bytesToHexStr(bytes));
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
state.lastSentMode.set(node, mode);
|
|
|
|
|
|
state.lastSentTargets.set(node, {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
mode,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
angle: parseFloat(cmdVal("inAngle")) || 0,
|
|
|
|
|
|
velocity: parseFloat(cmdVal("inVelocity")) || 0,
|
|
|
|
|
|
current: parseFloat(cmdVal("inCurrent")) || 0,
|
|
|
|
|
|
voltage: parseFloat(cmdVal("inVoltage")) || 0,
|
|
|
|
|
|
});
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!silent) toast(`CMD → Node ${node} · ${MODES[mode].zh} 发送成功`, "ok");
|
|
|
|
|
|
} else if (!res.networkError && !res.busy) {
|
2026-09-03 21:30:32 +08:00
|
|
|
|
toast(`CMD 发送失败: ${res.desc}`, "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function quickFieldOfMode(mode) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const def = MODES[mode];
|
|
|
|
|
|
if (!def || !def.targets.length) return null;
|
|
|
|
|
|
return def.targets[def.targets.length - 1];
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
function updateQuickHint() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const mode = parseInt(($("#modeSelect") || {}).value, 10) || 0;
|
|
|
|
|
|
const field = quickFieldOfMode(mode);
|
|
|
|
|
|
const el = $("#quickHint");
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
el.textContent = field ? `快捷发送:${field}` : "当前模式无快捷目标值";
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPeriod() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const v = parseInt(($("#repeatPeriod") || {}).value, 10);
|
|
|
|
|
|
return Number.isFinite(v) && v >= MIN_CMD_PERIOD_MS ? v : MIN_CMD_PERIOD_MS;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
let _repeatTimer = null;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function startRepeat() {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
stopRepeat(true);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const period = getPeriod();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
_repeatTimer = setInterval(() => { doSendCmd(true); }, period);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const hz = 1000 / period;
|
|
|
|
|
|
toast(`周期发送已启动 · ${period} ms(${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
|
|
|
|
|
|
}
|
|
|
|
|
|
function stopRepeat(silent) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (_repeatTimer) { clearInterval(_repeatTimer); _repeatTimer = null; }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (!silent) toast("周期发送已停止", "warn");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 配置操作(CMD_CONFIG) ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const CONFIG_ACTIONS = {
|
|
|
|
|
|
motReset: { title: "电机复位", bit: 0, desc: "将向节点发送 motReset 脉冲,电机立即停止并清除故障标志。运行中的电机将失去力矩输出,请确认机械安全。" },
|
|
|
|
|
|
saveConfig: { title: "保存配置", bit: 2, desc: "将当前控制模式和 PID 参数写入 EEPROM。写入期间约 50ms 不可发送新指令。" },
|
|
|
|
|
|
angleReset: { title: "角度置零", bit: 3, desc: "将当前机械位置设为电气零点。置零后目标角度将以新零点为参考,请确认电机处于期望的零位。" },
|
|
|
|
|
|
zeroCalib: { title: "零点校准", bit: 5, desc: "发送 zero_calibration 脉冲,对电机进行零点校准。请确认电机已处于期望的机械零位且无负载。" },
|
|
|
|
|
|
sleep: { title: "休眠", bit: 56, desc: "电机进入低功耗休眠模式,关闭驱动输出。需发送 CANFD_BOOT 或重新上电才能唤醒。" },
|
|
|
|
|
|
};
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 版本查询(R5:等待下沉 transport.waitVersion) ─── */
|
|
|
|
|
|
const VERSION_FULL_BYTES = 24;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
return `${major}.${minor}.${patch}.${build}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return { soft, hard, boot, byteLen: rb.length, complete: rb.length >= VERSION_FULL_BYTES };
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setVer(id, text) {
|
|
|
|
|
|
const el = $("#" + id);
|
|
|
|
|
|
if (!el) return;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!text || text === "--") {
|
2026-09-03 21:30:32 +08:00
|
|
|
|
el.textContent = "--";
|
|
|
|
|
|
el.classList.add("ver-empty");
|
2026-09-21 02:26:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
|
el.classList.remove("ver-empty");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreVersionUi(node) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const v = node != null ? state.versions.get(node) : null;
|
|
|
|
|
|
if (!v) { setVer("verSoft", null); setVer("verHard", null); setVer("verBoot", null); return; }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
function setVerWaiting(label) {
|
|
|
|
|
|
["verSoft", "verHard", "verBoot"].forEach((k) => {
|
|
|
|
|
|
const el = $("#" + k);
|
|
|
|
|
|
if (el) { el.textContent = label; el.classList.add("ver-empty"); }
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
$("#queryVerBtn").addEventListener("click", async () => {
|
|
|
|
|
|
if (!state.connected) { toast("设备未连接", "err"); return; }
|
|
|
|
|
|
if (!requireNode()) return;
|
|
|
|
|
|
if (inBusCooldown()) return;
|
|
|
|
|
|
const node = state.selectedNode;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const reqId = ID_GET_VERSION_REQ(node);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const bytes = new Uint8Array(24);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const isEcat = !!(state.tp && state.tp.id === "ecat");
|
|
|
|
|
|
const maxAttempts = isEcat ? 3 : 1;
|
|
|
|
|
|
setVerWaiting("查询中…");
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (state.tp.drainPendingRx) { try { await state.tp.drainPendingRx(); } catch (_) {} }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
let v = null;
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
let prevSeq = null, sentAt = nowMs();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const before = await state.tp.fetchVersionNode(node);
|
|
|
|
|
|
prevSeq = before ? before.seq : null;
|
|
|
|
|
|
sentAt = nowMs();
|
|
|
|
|
|
} catch (_) {}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const res = await sendOneFrameWhenFree(reqId, 24, bytesToHexStr(bytes));
|
|
|
|
|
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
|
|
|
|
$("#sbLastSend").textContent = isEcat
|
|
|
|
|
|
? `最近发送: GET_VERSION ${hexId(reqId)} · ECAT 第${attempt}/${maxAttempts}次 … ${time}`
|
|
|
|
|
|
: `最近发送: GET_VERSION 请求 ${hexId(reqId)} → 等应答 … ${time}`;
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
if (res.busy || res.networkError) {
|
|
|
|
|
|
if (attempt < maxAttempts) { await sleep(200); continue; }
|
|
|
|
|
|
setVerWaiting("--");
|
|
|
|
|
|
restoreVersionUi(node);
|
|
|
|
|
|
toast(res.busy ? "发送通道仍忙,请稍后重试" : "网络错误,版本查询未发出", "warn");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setVerWaiting("失败");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
toast(`GET_VERSION 发送失败: ${res.desc}`, "err");
|
2026-09-20 22:58:00 +08:00
|
|
|
|
return;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
state.txCount++;
|
|
|
|
|
|
const timeoutMs = (attempt < maxAttempts) ? 2000 : 3000;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
v = await state.tp.waitVersion(node, { prevSeq, sentAt, timeoutMs });
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (v && v.version) break;
|
|
|
|
|
|
if (attempt < maxAttempts) await sleep(100);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
setVerWaiting("无回报");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
toast(
|
2026-09-20 22:58:00 +08:00
|
|
|
|
isEcat
|
|
|
|
|
|
? `已发 ${hexId(reqId)}×${maxAttempts},Node ${node} 版本未进 xrx/motor/rx`
|
|
|
|
|
|
: `已发 ${hexId(reqId)},5s 内未收到电机新版本应答`,
|
2026-09-03 21:30:32 +08:00
|
|
|
|
"warn"
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 节点参数(R5:queryNodeIdParam / setNodeIdParam 下沉 transport) ─── */
|
2026-09-12 20:10:47 +08:00
|
|
|
|
const PARAM_SEL_NODE_ID = 0x0000;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const PARAM_SEL_STATUS = 0x0100;
|
|
|
|
|
|
const PARAM_SEL_CONFIG = 0x0101;
|
2026-09-12 20:10:47 +08:00
|
|
|
|
|
|
|
|
|
|
async function queryParam(node, selector) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!state.tp || typeof state.tp.queryNodeIdParam !== "function") {
|
|
|
|
|
|
return { ok: false, desc: "当前链路不支持节点参数查询" };
|
2026-09-12 20:10:47 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return state.tp.queryNodeIdParam(node, selector);
|
2026-09-12 20:10:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setParam(node, selector, value) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (!state.tp || typeof state.tp.setNodeIdParam !== "function") {
|
|
|
|
|
|
return { ok: false, desc: "当前链路不支持节点参数设置" };
|
2026-09-12 20:10:47 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return state.tp.setNodeIdParam(node, selector, value);
|
2026-09-12 20:10:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const PERIOD_MAX_HZ = 1000;
|
|
|
|
|
|
function periodToHzText(ms) {
|
|
|
|
|
|
if (ms === 0) return "停发";
|
|
|
|
|
|
const hz = 1000 / ms;
|
|
|
|
|
|
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
|
|
|
|
|
|
}
|
|
|
|
|
|
function periodToHzInput(ms) {
|
|
|
|
|
|
if (ms === 0) return 0;
|
|
|
|
|
|
return Math.round((1000 / ms) * 100) / 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
function periodRowEls(kind) {
|
|
|
|
|
|
return kind === "status"
|
|
|
|
|
|
? { sel: PARAM_SEL_STATUS, name: "STATUS", input: $("#periodStatusInput") }
|
|
|
|
|
|
: { sel: PARAM_SEL_CONFIG, name: "CONFIG", input: $("#periodConfigInput") };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function readPeriodRow(kind) {
|
|
|
|
|
|
if (!state.connected) { toast("设备未连接", "err"); return; }
|
|
|
|
|
|
if (!requireNode()) return;
|
|
|
|
|
|
if (inBusCooldown()) return;
|
|
|
|
|
|
const node = state.selectedNode;
|
|
|
|
|
|
const row = periodRowEls(kind);
|
|
|
|
|
|
const result = $("#periodResult");
|
|
|
|
|
|
const id = ID_GET_NODE_ID(node);
|
|
|
|
|
|
result.textContent = `${row.name} 读取中…`;
|
|
|
|
|
|
const r = await queryParam(node, row.sel);
|
|
|
|
|
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
|
|
|
|
$("#sbLastSend").textContent = `最近发送: GET 刷新率(${row.name}) → ${hexId(id)} [2B] … ${time}`;
|
|
|
|
|
|
if (r.ok && !r.oldFw) {
|
|
|
|
|
|
row.input.value = periodToHzInput(r.val);
|
|
|
|
|
|
result.textContent = `${row.name} 刷新率 = ${periodToHzText(r.val)}(周期 ${r.val} ms) · 节点 ${node}`;
|
|
|
|
|
|
toast(`${row.name} 刷新率已读取: ${periodToHzText(r.val)}`, "ok");
|
|
|
|
|
|
} else if (r.ok && r.oldFw) {
|
|
|
|
|
|
result.textContent = `${row.name} 读取失败:电机固件过旧(回报的是节点 ID),不支持刷新率参数`;
|
|
|
|
|
|
toast("电机固件不支持刷新率参数(需 v0.4.1+)", "warn");
|
|
|
|
|
|
} else if (!r.networkError && !r.busy) {
|
|
|
|
|
|
result.textContent = `${row.name} 读取失败: ${r.desc}`;
|
|
|
|
|
|
toast(`${row.name} 读取失败: ${r.desc}`, "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function writePeriodRow(kind) {
|
|
|
|
|
|
if (!state.connected) { toast("设备未连接", "err"); return; }
|
|
|
|
|
|
if (!requireNode()) return;
|
|
|
|
|
|
if (inBusCooldown()) return;
|
|
|
|
|
|
const node = state.selectedNode;
|
|
|
|
|
|
const row = periodRowEls(kind);
|
|
|
|
|
|
const result = $("#periodResult");
|
|
|
|
|
|
const hz = parseFloat(row.input.value);
|
|
|
|
|
|
if (!Number.isFinite(hz) || hz < 0 || hz > PERIOD_MAX_HZ) {
|
|
|
|
|
|
toast(`${row.name} 刷新率范围 0~${PERIOD_MAX_HZ} Hz(0=停发)`, "err");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const period = hz === 0 ? 0 : Math.round(1000 / hz);
|
|
|
|
|
|
if (hz > 0 && (period < 1 || period > 0xFFFF)) {
|
|
|
|
|
|
toast(`${row.name} 刷新率过低(周期超出 1~65535 ms)`, "err");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const warn = (hz === 0 && kind === "status")
|
|
|
|
|
|
? "STATUS 帧将停发,右侧状态面板会显示离线(电机仍可控可刷写)。"
|
|
|
|
|
|
: (hz === 0 ? "CONFIG 帧将停发,目标值回显将不可用。" : "");
|
|
|
|
|
|
const ok = await showModal("设置反馈刷新率",
|
|
|
|
|
|
`将节点 ${node} 的 ${row.name} 帧刷新率设为 ${row.input.value} Hz(周期 ${period} ms),立即生效并写入电机 Flash。${warn}`);
|
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
|
const id = ID_SET_NODE_ID(node);
|
|
|
|
|
|
result.textContent = `${row.name} 写入中…`;
|
|
|
|
|
|
const r = await setParam(node, row.sel, period);
|
|
|
|
|
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
|
|
|
|
$("#sbLastSend").textContent = `最近发送: SET 刷新率(${row.name}) → ${hexId(id)} [4B] ${period}ms(${row.input.value}Hz) … ${time}`;
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
state.txCount++;
|
|
|
|
|
|
result.textContent = `${row.name} 刷新率 = ${periodToHzText(period)}(周期 ${period} ms)已生效并写入 Flash · 节点 ${node}`;
|
|
|
|
|
|
toast(`${row.name} 刷新率已设为 ${periodToHzText(period)}`, "ok");
|
|
|
|
|
|
saveUiState();
|
|
|
|
|
|
} else if (!r.networkError && !r.busy) {
|
|
|
|
|
|
result.textContent = `${row.name} 写入失败: ${r.desc}`;
|
|
|
|
|
|
toast(`${row.name} 写入失败: ${r.desc}`, "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$("#getPeriodStatusBtn").addEventListener("click", () => readPeriodRow("status"));
|
|
|
|
|
|
$("#setPeriodStatusBtn").addEventListener("click", () => writePeriodRow("status"));
|
|
|
|
|
|
$("#getPeriodConfigBtn").addEventListener("click", () => readPeriodRow("config"));
|
|
|
|
|
|
$("#setPeriodConfigBtn").addEventListener("click", () => writePeriodRow("config"));
|
|
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
$("#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;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* 旧格式 DLC2 保持不变:NodeID_Set 0|16@1+ = 新节点号 u16 LE */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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;
|
|
|
|
|
|
const id = ID_GET_NODE_ID(cur);
|
2026-09-12 20:10:47 +08:00
|
|
|
|
const res = await queryParam(cur, PARAM_SEL_NODE_ID);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
2026-09-12 20:10:47 +08:00
|
|
|
|
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] sel=0x0000 … ${time}`;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
state.txCount++;
|
2026-09-12 20:10:47 +08:00
|
|
|
|
$("#nodeIdResult").textContent = `节点 ${cur} 回报 ID: ${res.val} · ${hexId(id)}`;
|
|
|
|
|
|
toast(`GET_NODE_ID → Node ${cur} 已读取: ${res.val}`, "ok");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
} else if (!res.networkError && !res.busy) {
|
|
|
|
|
|
$("#nodeIdResult").textContent = `读取失败: ${res.desc}`;
|
|
|
|
|
|
toast(`GET_NODE_ID 失败: ${res.desc}`, "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── Tab 切换 ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
|
|
|
|
|
btn.addEventListener("click", () => {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (btn.disabled || btn.classList.contains("tab-disabled")) return;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ─── 通道切换 / 刷新速度 ─── */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
$("#chSelect").addEventListener("change", () => {
|
|
|
|
|
|
state.ch = parseInt($("#chSelect").value, 10) || 0;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (scopeSys) scopeSys.reset();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
updateConnUi();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
updateChSelectOptions();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
refreshChannelViews();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
saveUiState();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const refreshRateInput = $("#refreshRate");
|
|
|
|
|
|
|
|
|
|
|
|
async function sendMotorPeriod(ms) {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!state.tp.supportsPeriodSync) return;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
try {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
await state.tp.setPeriod(ms);
|
2026-09-21 02:26:39 +08:00
|
|
|
|
} catch (e) { /* 周期下发失败静默 */ }
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
|
2026-09-03 21:30:32 +08:00
|
|
|
|
function applyRefreshRate() {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const lo = minRefreshFor(state.tpId);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
refreshRateInput.value = v;
|
|
|
|
|
|
state.refreshIntervalMs = v;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
state.refreshByTp[state.tpId] = v;
|
|
|
|
|
|
saveUiState();
|
|
|
|
|
|
updateNodeLabels();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (state.poll.tick) {
|
|
|
|
|
|
clearTimeout(state.poll.tick);
|
|
|
|
|
|
state.poll.tick = null;
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (state.connected && !state.probing && late.connSys) {
|
|
|
|
|
|
late.connSys.setTickNextAt(0);
|
|
|
|
|
|
late.connSys.schedulePollLoop(state.pollGen, 0);
|
|
|
|
|
|
sendMotorPeriod(v);
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
const hz = 1000 / v;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
toast(isUnthrottled(v)
|
|
|
|
|
|
? `${state.tp.label} 刷新速度 ${v} ms(不限速,实际频率见 STATUS 面板)`
|
|
|
|
|
|
: `${state.tp.label} 刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
refreshRateInput.addEventListener("change", applyRefreshRate);
|
|
|
|
|
|
refreshRateInput.addEventListener("input", () => {
|
|
|
|
|
|
const raw = parseInt(refreshRateInput.value, 10);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const lo = minRefreshFor(state.tpId);
|
|
|
|
|
|
if (Number.isFinite(raw) && raw < lo) refreshRateInput.value = lo;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* ═══════════════════════ 模块装配(P1 R4) ═══════════════════════ */
|
|
|
|
|
|
let scopeSys = null;
|
|
|
|
|
|
let otaSys = null;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const seqSys = createSeqSys({
|
|
|
|
|
|
MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $,
|
|
|
|
|
|
cmdVal, syncCmdCommittedFromDom, saveUiState, debounceSaveUi,
|
|
|
|
|
|
toast, showModal, inBusCooldown, sendBurst, stopRepeat,
|
|
|
|
|
|
renderNodeList, nowMs, sleep, late,
|
|
|
|
|
|
});
|
|
|
|
|
|
const seq = seqSys.seq;
|
|
|
|
|
|
const renderSeqList = seqSys.renderSeqList;
|
|
|
|
|
|
const updateSeqUi = seqSys.updateSeqUi;
|
|
|
|
|
|
const startSeq = seqSys.startSeq;
|
|
|
|
|
|
const stopSeq = seqSys.stopSeq;
|
|
|
|
|
|
const normStep = seqSys.normStep;
|
|
|
|
|
|
late.stopSeq = stopSeq;
|
|
|
|
|
|
late.stopRepeat = stopRepeat;
|
|
|
|
|
|
late.sendMotorPeriod = sendMotorPeriod;
|
|
|
|
|
|
late.recordSlowReq = recordSlowReq;
|
|
|
|
|
|
late.isTransientError = isTransientError;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
otaSys = createOtaSys({
|
|
|
|
|
|
state, $, toast, showModal, escapeHtml, sleep,
|
|
|
|
|
|
canId, hexId, nodeHex, bytesToHexStr,
|
|
|
|
|
|
sendBatch, sendOneFrameWhenFree, getBaseUrl,
|
|
|
|
|
|
ID_GET_VERSION_REQ, parseVersionPayload, setVer, saveUiState,
|
|
|
|
|
|
late,
|
|
|
|
|
|
});
|
|
|
|
|
|
const otaRestorePersistedFirmware = otaSys.otaRestorePersistedFirmware;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
const connSys = createConnPoll({
|
|
|
|
|
|
state, $, toast, deviceLink, flashBusyNow, isUnthrottled,
|
|
|
|
|
|
getSharedDeviceIp, setSharedDeviceIp,
|
|
|
|
|
|
onMotorState, onFeedback, updateTickStuckUi,
|
|
|
|
|
|
setQuietMode, late,
|
|
|
|
|
|
});
|
|
|
|
|
|
late.connSys = connSys;
|
|
|
|
|
|
late.stopDataPolling = connSys.stopDataPolling;
|
|
|
|
|
|
late.startPolling = connSys.startPolling;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
scopeSys = createScopeSys({ state, motorKey, clampNum, $ });
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
|
|
|
|
|
/* ═══════════════════════ 初始化 ═══════════════════════ */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
useTransport(loadTransportId());
|
2026-09-03 21:30:32 +08:00
|
|
|
|
state.ip = getSharedDeviceIp();
|
2026-09-16 12:12:24 +08:00
|
|
|
|
deviceLink.subscribe(function (snap) {
|
|
|
|
|
|
if (!snap || !snap.ip) return;
|
|
|
|
|
|
if (snap.ip !== state.ip) {
|
|
|
|
|
|
state.ip = snap.ip;
|
|
|
|
|
|
const label = $("#ipLabel");
|
|
|
|
|
|
if (label) label.textContent = "IP " + state.ip;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-03 21:30:32 +08:00
|
|
|
|
loadUiState();
|
2026-09-04 12:18:06 +08:00
|
|
|
|
applyTransportRefresh();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
|
|
|
|
|
|
state.selectedNode = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
applyModeVisibility();
|
|
|
|
|
|
updateNodeLabels();
|
|
|
|
|
|
updateConnUi();
|
|
|
|
|
|
renderNodeList();
|
|
|
|
|
|
renderStatusPanel();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
restoreVersionUi(state.selectedNode);
|
|
|
|
|
|
renderSeqList();
|
|
|
|
|
|
const seqLoopsInput = $("#seqLoops");
|
|
|
|
|
|
if (seqLoopsInput) seqLoopsInput.value = seq.loops;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
bindAutoSave();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
initCmdCommit();
|
|
|
|
|
|
otaRestorePersistedFirmware();
|
2026-09-03 21:30:32 +08:00
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const transportSelect = $("#transportSelect");
|
|
|
|
|
|
if (transportSelect) {
|
|
|
|
|
|
transportSelect.value = state.tpId;
|
|
|
|
|
|
transportSelect.addEventListener("change", () => {
|
|
|
|
|
|
switchTransport(transportSelect.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const ecatAdapterInput = $("#ecatAdapter");
|
|
|
|
|
|
if (ecatAdapterInput) {
|
|
|
|
|
|
ecatAdapterInput.addEventListener("change", async () => {
|
|
|
|
|
|
const tp = state.tp;
|
|
|
|
|
|
if (!tp.setAdapter) return;
|
|
|
|
|
|
const changed = await tp.setAdapter(ecatAdapterInput.value);
|
|
|
|
|
|
if (!changed) { ecatAdapterInput.value = tp.getAdapter(); return; }
|
|
|
|
|
|
toast("网卡已切换到 " + tp.getAdapter() + ",正在重连…", "ok");
|
|
|
|
|
|
stopDataPolling();
|
|
|
|
|
|
state.connected = false;
|
|
|
|
|
|
connectShared();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
applyTransportUi();
|
2026-09-04 12:18:06 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* CMD / 周期发送按钮(依赖 doSendCmd / startSeq 等) */
|
|
|
|
|
|
const sendCmdBtn = $("#sendCmdBtn");
|
|
|
|
|
|
if (sendCmdBtn) sendCmdBtn.addEventListener("click", () => doSendCmd(false));
|
|
|
|
|
|
const quickSendBtn = $("#quickSendBtn");
|
|
|
|
|
|
if (quickSendBtn) quickSendBtn.addEventListener("click", () => doSendCmd(false));
|
|
|
|
|
|
const modeSelect = $("#modeSelect");
|
|
|
|
|
|
if (modeSelect) modeSelect.addEventListener("change", applyModeVisibility);
|
|
|
|
|
|
const repeatStartBtn = $("#repeatStartBtn");
|
|
|
|
|
|
if (repeatStartBtn) repeatStartBtn.addEventListener("click", startRepeat);
|
|
|
|
|
|
const repeatStopBtn = $("#repeatStopBtn");
|
|
|
|
|
|
if (repeatStopBtn) repeatStopBtn.addEventListener("click", () => stopRepeat(false));
|
|
|
|
|
|
|
|
|
|
|
|
/* 暴露诊断对象到 window,供控制台排查慢请求 */
|
2026-09-03 21:30:32 +08:00
|
|
|
|
window.phaseDebug = {
|
|
|
|
|
|
get lastSlowReq() { return state.phaseDebug && state.phaseDebug.lastSlowReq; },
|
|
|
|
|
|
get slowStats() { return state.phaseDebug && state.phaseDebug.slowStats; },
|
2026-09-20 22:58:00 +08:00
|
|
|
|
get lastHardFail() { return state.phaseDebug && state.phaseDebug.lastHardFail; },
|
2026-09-03 21:30:32 +08:00
|
|
|
|
reset() {
|
|
|
|
|
|
if (state.phaseDebug) {
|
|
|
|
|
|
state.phaseDebug.lastSlowReq = null;
|
|
|
|
|
|
state.phaseDebug.slowStats = {};
|
2026-09-20 22:58:00 +08:00
|
|
|
|
state.phaseDebug.lastHardFail = null;
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
console.log("[phaseDebug] 慢请求统计已清空");
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
(async () => {
|
|
|
|
|
|
try { await hydrateUiFromDisk(); } catch (_) {}
|
|
|
|
|
|
connectShared();
|
|
|
|
|
|
})();
|