Files
DCU_DesktopController/js/motor/transport-wifi.js
T
cbb 261caf50b7 refactor(motor): P1 拆分 main.js + 收敛连接/总线状态机
R4 外提 scope/ota/seq/conn-poll;R5 waitVersion/queryNodeIdParam 下沉
transport;R6 ecat-bus 单点 bootstrap;R7 toast-stack + utils 去重。
main.js 4647→~1894 行。?v= 已 bump 20260924a。
2026-09-21 02:26:39 +08:00

387 lines
16 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";
function bytesToHexStr(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i++) {
s += bytes[i].toString(16).padStart(2, "0").toUpperCase();
}
return s;
}
function hexToBytes(hex) {
const clean = String(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 u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
const ID_GET_NODE_ID = (n) => canId(n, 0x0401);
const ID_SET_NODE_ID = (n) => canId(n, 0x0400);
/** @description 创建 WiFi/HTTP 传输实例。 */
export function createWifiTransport(ctx) {
const { getBaseUrl, recordSlow, isTransient } = ctx;
const sendFrame = ctx.sendFrame;
const getCh = typeof ctx.getCh === "function" ? ctx.getCh : () => 0;
/** 统一 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),EtherCAT 侧没有这条链路 */
supportsOta: true,
/** 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;
},
/**
* R5:版本等待下沉传输层。
* WiFi 走 GD32 缓存 /motor/version,按 seq 增长或 at>=sentAt 判新应答。
* 超时返回 null(与旧 waitMotorVersionOnTick 语义一致)。
*/
async waitVersion(node, opts) {
const prevSeq = opts && opts.prevSeq;
const sentAt = (opts && opts.sentAt) || 0;
const timeoutMs = (opts && opts.timeoutMs) || 5000;
const deadline = Date.now() + timeoutMs;
const sampleOnce = async () => {
try {
const v = await getJson("/motor/version", 2000);
if (!v || !v.have_data || !Array.isArray(v.nodes)) return null;
const hit = v.nodes.find((n) => n.node === node) || null;
if (hit && hit.version &&
(prevSeq == null || hit.seq > prevSeq || (hit.at && hit.at >= sentAt))) {
return hit;
}
} catch (_) { /* 瞬时 busy 忽略 */ }
return null;
};
let v = await sampleOnce();
if (v) return v;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
v = await sampleOnce();
if (v) return v;
}
return null;
},
/**
* R5:节点参数 GET(0x000N0401 扩展格式)。
* 发送走注入的 sendOneFrameWhenFree(保持 main 流控);回执走 GD32 per-ID 快照。
*/
async queryNodeIdParam(node, selector) {
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
const id = ID_GET_NODE_ID(node);
const res = await sendFrame(id, 2, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 2) {
const b = hexToBytes(r.recv);
if (b.length === 2 || b.length === 4) rb = b;
}
if (!rb) {
await new Promise((r2) => setTimeout(r2, 80));
try {
const fb = await getJson("/can/feedback", 2500);
const ch = (fb && fb.channels) ? fb.channels[getCh()] : null;
if (ch && ch.valid && Array.isArray(ch.entries)) {
const hit = ch.entries.find((e) => e && Number(e.id) === id);
if (hit) rb = hexToBytes(hit.data);
}
} catch (_) {}
}
return parseParamReply(rb, selector);
},
/** R5:节点参数 SET(0x000N0400 扩展格式 DLC4)。 */
async setNodeIdParam(node, selector, value) {
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
const bytes = new Uint8Array([
selector & 0xFF, (selector >> 8) & 0xFF,
value & 0xFF, (value >> 8) & 0xFF,
]);
const id = ID_SET_NODE_ID(node);
const res = await sendFrame(id, 4, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
if (!rb) {
await new Promise((r2) => setTimeout(r2, 80));
try {
const fb = await getJson("/can/feedback", 2500);
const ch = (fb && fb.channels) ? fb.channels[getCh()] : null;
if (ch && ch.valid && Array.isArray(ch.entries)) {
const hit = ch.entries.find((e) => e && Number(e.id) === id);
if (hit) rb = hexToBytes(hit.data);
}
} catch (_) {}
}
return parseSetReply(rb, selector);
},
/** 把前端刷新周期同步给 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 */ },
};
}
function parseParamReply(rb, selector) {
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 4) {
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return { ok: true, val };
}
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
return { ok: false, desc: "应答格式异常" };
}
function parseSetReply(rb, selector) {
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 5) {
const st = rb[0], sel = u16LeAt(rb, 1);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
}
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
}