feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+33 -12
View File
@@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js";
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
import {
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
} from "./transport.js?v=20260923i";
} from "./transport.js?v=20261011b";
import { deviceLink } from "../common/device-link.js";
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
@@ -52,7 +52,7 @@ import {
} from "./refresh-rate.js";
import { createScopeSys } from "./scope.js?v=20260924f";
import { createSeqSys } from "./seq.js?v=20260924f";
import { createOtaSys } from "./ota.js?v=20260924g";
import { createOtaSys } from "./ota.js?v=20261011c";
/* ─── 控制模式定义 ─── */
@@ -780,7 +780,9 @@ function bytesToHexStr(bytes) {
const GD32_ERR_DESC = {
0xE0: "payload 过短", 0xE1: "通道号非法",
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xE6: "只听不发(该通道收发器已 Silent,接收仍正常)",
0xE7: "该通道正被电机 OTA 独占(OTA 结束自动恢复;不是总线故障,重试也没用)",
0xEE: "SPI 帧解析失败",
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
};
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
@@ -829,6 +831,14 @@ async function sendBatch(frames, opts) {
await sleep(backoff);
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):确定性拒发,不是总线故障。
* 不冷却也不重试——冷却治不了,要人把静默切回去;只停周期与序列。 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -906,6 +916,13 @@ async function sendBurst(actions, opts) {
await sleep(backoff);
return sendBurst(actions, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):同 sendBatch,确定性拒发,不冷却不重试 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -1117,16 +1134,16 @@ function applyTransportUi() {
if (show) ad.value = tp.getAdapter();
}
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
/* 电机 OTA 由传输层给整套钩子(WiFi = ESP32 上传 + SPI 中继;EtherCAT = CoE 邮箱
* 灌进 GD32 暂存区)。哪条链路缺钩子就禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清缺在哪。 */
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
if (otaBtn) {
const off = !tp.supportsOta;
const off = !tp.supportsOta || typeof tp.otaUploadAttempt !== "function";
otaBtn.disabled = off;
otaBtn.classList.toggle("tab-disabled", off);
otaBtn.title = off
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
? "当前链路没有电机 OTA 通路(需要固件进料 + GD32 relay 两套钩子)"
: "";
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
if (off && otaBtn.classList.contains("active")) {
@@ -2282,9 +2299,11 @@ function busStateText(bs) {
if (bs === 0) return "ACTIVE";
const parts = [];
if (bs & 1) parts.push("INIT");
if (bs & 2) parts.push("SLEEP");
if (bs & 2) parts.push("控制器SLEEP");
if (bs & 4) parts.push("ERR_PASSIVE");
if (bs & 8) parts.push("BUS_OFF");
if (bs & 16) parts.push("ERR_WARN");
if (bs & 32) parts.push("只听不发");
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
@@ -2344,9 +2363,11 @@ function onFeedback(data) {
busEl.textContent = bsText;
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const isSilent = valid && ch.bus_state != null && (ch.bus_state & 32);
busEl.style.color = isBusOff ? "var(--danger)" : (isPassive || isSilent) ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const sbLed = $("#sbLed");
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
/* 静默=能收不能发,灯不能继续给"全绿":与右侧文字同源判定 */
sbLed.className = "led " + (isBusOff ? "led-err" : isSilent ? "led-warn" : (valid ? "led-on" : ""));
}
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
@@ -3069,7 +3090,7 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
* 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
const otaSys = createOtaSys({
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
});