/** * 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),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} 已按帧数补齐的 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; }, /** 把前端刷新周期同步给 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 */ }, }; }