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。
387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
/**
|
||
* 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+)" };
|
||
}
|