Files
DCU_DesktopController/js/motor/transport-wifi.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

323 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* transport-wifi.js — 电机控制台的 WiFi / HTTP 传输实现
*
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
* GET /system/status 探测 + 健康检查(lite/full 交替)
* GET /motor/state?bin=1 电机状态(二进制,27 槽满 ~2508B)
* GET /can/feedback?lite=1 CAN 计数/总线状态
* POST /can/send_batch 发送(唯一入口)
* POST /can/send_burst 突发同时发送(多轴)
* GET /motor/version 版本缓存(GD32 经 0xC6 同步给 ESP32)
* POST /spi/send 0xC5 推送周期对齐
*
* 本模块只做"发请求 + 校验响应形状",**不含任何流控**:
* 互斥、0xE4 退避重试、0xE2/0xE3 冷却、latest-wins 排空全部留在 main.js,
* 因为那些是与传输无关的业务策略,两种传输必须共用同一套,否则 ECAT
* 传输会悄悄失去这些保护(这正是"所有组件复用"的要点)。
*
* ctx 依赖(由 main.js 注入,避免与 main.js 形成循环 import):
* getBaseUrl() → "http://<设备IP>"
* recordSlow(path,ms,err?) 慢请求统计
* isTransient(err) → bool,500/503/超时归为瞬时
*/
"use strict";
/** @description 创建 WiFi/HTTP 传输实例。 */
export function createWifiTransport(ctx) {
const { getBaseUrl, recordSlow, isTransient } = ctx;
/** 统一 GET JSON。失败抛 Error,附 status / transient(与原 fetchDeviceJson 一致)。 */
async function getJson(path, timeoutMs) {
let resp;
const t0 = performance.now();
try {
resp = await fetch(getBaseUrl() + path, {
signal: AbortSignal.timeout(timeoutMs || 3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow(path, performance.now() - t0, err.message);
throw err;
}
recordSlow(path, performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return resp.json();
}
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
* 双版本兼容(设备固件与前端升级节奏不一定同步):
* ver=1 v0.70:entry 76,data 32(CONFIG 在 44/48/52)
* ver=2 v0.74:entry 92,data 48(字节 32~35 = InitStatus,CONFIG 后移到 60/64/68)
* data 32B 时 decodeStatus 高位补 0 → initStatus=0,上层无需感知版本。
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
function parseMotorStateBinary(ab) {
const out = { have_data: false, active_count: 0, age_ms: null, boot_active: false, motors: [] };
if (!ab || ab.byteLength < 12) return out;
const dv = new DataView(ab);
if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */
const ver = dv.getUint8(2);
if (ver !== 1 && ver !== 2) return out;
const dataLen = (ver === 2) ? 48 : 32;
const entry = 12 + dataLen + 8 + 24; /* 92 / 76(4+4+24 = config 段) */
const cfgCntOff = 12 + dataLen; /* config_rx_count 相对 entry 起点 */
const cfgSeqOff = cfgCntOff + 4;
const flags = dv.getUint8(3);
const age = dv.getUint32(4, true);
const active = dv.getUint8(8);
const count = dv.getUint8(9);
const have = (age !== 0xFFFFFFFF);
out.have_data = have;
out.active_count = active;
out.age_ms = have ? age : null;
out.boot_active = (flags & 0x01) !== 0;
const u8 = new Uint8Array(ab);
let off = 12;
const hexOf = (start, len) => {
let s = "";
for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase();
return s;
};
for (let i = 0; i < count && off + entry <= ab.byteLength; i++) {
out.motors.push({
node: u8[off],
ch: u8[off + 1],
online: u8[off + 2] !== 0,
dlc: u8[off + 3],
rx_count: dv.getUint32(off + 4, true),
seq: dv.getUint32(off + 8, true),
data: hexOf(off + 12, dataLen),
config_rx_count: dv.getUint32(off + cfgCntOff, true),
config_seq: dv.getUint32(off + cfgSeqOff, true),
config_data: hexOf(off + cfgSeqOff + 4, 24),
});
off += entry;
}
return out;
}
/** POST JSON,返回解析后的 json;HTTP 非 2xx / 响应缺 results 时抛错。 */
async function postFrames(path, payload, timeoutMs, frameCount) {
const resp = await fetch(getBaseUrl() + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
/* 响应短于请求帧数:用 abort_code 补齐,让上层按帧一一对应地报错 */
const results = json.results.slice();
const need = frameCount - results.length;
if (need > 0) {
const pad = {
status: "error",
code: json.abort_code != null ? json.abort_code : -2,
gd32_err: json.abort_gd32_err || 0,
};
for (let p = 0; p < need; p++) results.push(pad);
}
return results;
}
return {
id: "wifi",
label: "WiFi / HTTP",
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay) */
supportsOta: true,
/** 进 BOOT 帧由上位机发(既有顺序:先帧后 relay),锁还没上,通道此刻是空的 */
otaHostSendsBootFrame: true,
/** 完整性口径:ESP32 从 flash 读回算 SHA-256,与浏览器算的比 */
otaIntegrity: "sha256-esp32-flash",
/** 0xC5 推送周期可与前端刷新率对齐 */
supportsPeriodSync: true,
sendHint(ch) { return "POST /can/send_batch \u00b7 CH" + ch; },
/** 连接探测:/system/status 能通即视为在线(与原 probeOnce 完全一致)。 */
async probe() { return getJson("/system/status", 2500); },
/** 健康检查:lite ~150B / full ~3KB(5s 一次)。 */
async fetchStatus(wantFull) {
return getJson(wantFull ? "/system/status" : "/system/status?lite=1",
wantFull ? 3500 : 1500);
},
/** 设备状态摘要 → #devStatus 文本片段数组。 */
describeStatus(data) {
const wifi = (data && data.wifi) || {};
const parts = [];
if (data && data.gd32_features && data.gd32_features.known) {
parts.push("feat 0x" + (data.gd32_features.flags >>> 0).toString(16).toUpperCase());
}
if (data && data.app_version) parts.push("APP " + data.app_version);
if (data && data.boot_version) parts.push("BOOT " + data.boot_version);
if (wifi.mode) parts.unshift(wifi.mode + (wifi.connected ? " 在线" : " 离线"));
return parts;
},
/** 设备回报的 IP(用于写回共享 localStorage);ECAT 传输无此概念。 */
reportedIp(data) { return (data && data.wifi && data.wifi.ip) || null; },
async fetchMotorState() {
const t0 = performance.now();
let resp;
try {
resp = await fetch(getBaseUrl() + "/motor/state?bin=1", {
signal: AbortSignal.timeout(3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow("/motor/state?bin=1", performance.now() - t0, err.message);
throw err;
}
recordSlow("/motor/state?bin=1", performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return parseMotorStateBinary(await resp.arrayBuffer());
},
async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); },
/**
* 全量 CAN 反馈快照(含 per-ID entries)。
* 请求-应答类命令(节点 ID / 反馈刷新率参数 0x0400·0x0401)在 WiFi 链路的
* 回执通道:GD32 的 0xC0 ACK 只有 1 字节状态、不携带电机回帧,回帧落在
* GD32 的 per-ID 快照表里,经这里按需拉取。ECAT 链路不需要(走 motor/rx)。
*/
async fetchFeedbackFull() { return getJson("/can/feedback", 2500); },
/**
* 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。
* @returns {Promise<object[]>} 已按帧数补齐的 results[]
*/
async sendFrames(frames, opts) {
const n = frames.length;
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量 */
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
return postFrames("/can/send_batch", { ch: opts.ch, frames }, timeoutMs, n);
},
/**
* 突发(近同时)发送:ESP32 转 0xC7,GD32 背靠背装载各通道邮箱,组内偏差 µs 级。
* frames = [{ch,node,data}],共用 suffix + dlc。
*/
async sendBurst(frames, opts) {
const timeoutMs = Math.max(3000, frames.length * 250);
return postFrames("/can/send_burst",
{ suffix: opts.suffix, dlc: opts.dlc, frames },
timeoutMs, frames.length);
},
/** 版本缓存查询(GD32 解析后经 0xC6 同步,ESP32 提供缓存)。 */
async fetchVersionNode(node) {
const data = await getJson("/motor/version", 2000);
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
return data.nodes.find((n) => n.node === node) || null;
},
/* ── 电机 OTA(ESP32 上传 + SPI 中继)────────────────────────────
* 还是原来那三条 HTTP:POST /motor/ota(写 ESP32 flash 并回读算 SHA-256)、
* POST /motor/ota/relay(SPI 0xD0 START + feedTask 喂数)、GET /motor/ota/status。
* 收进传输契约只有一个原因:ota.js 原先硬写 getBaseUrl(),等于假设「设备 IP
* 一定打得通」——纯 EtherCAT 场合那条前提不成立。行为与外提前完全一致。 */
/** 一次上传尝试:POST 整份镜像,拿设备回读的 flash SHA-256 与浏览器算的比。
* 不抛错——失败是这里的正常出口(ota.js 据此决定重传,最多 3 次)。 */
async otaUploadAttempt(bytes, shaHex, opts) {
const ch = (opts && opts.ch) | 0;
let json = {};
try {
const resp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes,
signal: AbortSignal.timeout(15000),
});
json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
const size = Number(json.size) || bytes.length;
if (!json.sha256 || String(json.sha256).length !== 64) {
/* 旧 ESP32 固件不回 SHA:没有对端读数可比,如实说明后放行(沿用既有行为),
* 后续 relay 若被拒会由那条报错兜住。 */
return { ok: true, size, note: "设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对" };
}
const dev = String(json.sha256).toLowerCase();
return {
ok: dev === String(shaHex).toLowerCase(),
size,
note: dev === String(shaHex).toLowerCase()
? "flash 回读 SHA-256 一致"
: `flash SHA 不一致(设备 ${dev.slice(0, 16)}…)`,
};
},
/** 触发中继:GD32 开始握手,ESP32 起 feedTask 喂 SPI。失败抛错(上层按启动失败处理)。 */
async otaStartRelay(ch) {
const resp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${(ch | 0)}`, {
method: "POST",
signal: AbortSignal.timeout(8000),
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
return { ok: true };
},
/** 进度:ESP32 已经给字符串状态,直接透传;session_error/note 是 ECAT 才有的字段,
* 这里补空值,让上层的显示逻辑不用分链路写两遍。 */
async otaStatus() {
const st = await getJson("/motor/ota/status", 2000);
return Object.assign({ session_error: 0, note: "" }, st);
},
/** 兜底清理:让 ESP32 退出 feedTask + GD32 resetToIdle。 */
async otaCancel() {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", {
method: "POST",
signal: AbortSignal.timeout(5000),
});
return { ok: r.ok };
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
},
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
async setPeriod(ms) {
const v = Math.max(20, Math.min(65535, Math.round(ms)));
const lo = (v & 0xFF).toString(16).padStart(2, "0").toUpperCase();
const hi = ((v >> 8) & 0xFF).toString(16).padStart(2, "0").toUpperCase();
await fetch(getBaseUrl() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "C5", data: "FE" + lo + hi, timeout_ms: 2000 }),
signal: AbortSignal.timeout(3000),
});
},
/** 切走本传输时的收尾:HTTP 无状态,无需处理。 */
async teardown() { /* no-op */ },
};
}