Files
DCU_DesktopController/js/motor/transport-wifi.js
T
cbb 209a869f96 feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应
反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式,
选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms):
- 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz
  (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期,
  取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准
- WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries):
  GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新
  一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写)
- ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK
- GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答
  (SET DLC2 旧格式行为不变)
- transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照)

EtherCAT/基础设施(密排槽固件 1.6.21 配套):
- 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔
  自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线;
  STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉)
- esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例,
  双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘
- flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零),
  防"写入≠在跑"假台账
- lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板
  并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时
  优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
2026-09-12 20:10:47 +08:00

249 lines
10 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),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;
},
/** 把前端刷新周期同步给 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 */ },
};
}