feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
+146
-13
@@ -55,6 +55,31 @@ import {
|
||||
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
|
||||
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
|
||||
const MOT_SLOTS_PER_PDO = 9;
|
||||
/** GD32 `MotorOtaRelay::State` 的数字状态 → WiFi 链路那套字符串(ota.js 的阶段条与
|
||||
* 日志只认字符串,两条链路必须翻成同一个口径,否则 UI 要为两种传输各写一遍判据)。
|
||||
* 序号改了要同步 DCU_APP sys/motor_ota_relay.h 的枚举 —— 那边是唯一真源。 */
|
||||
const RELAY_STATE_NAMES = [
|
||||
"idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase",
|
||||
"transfer", "finish_ota", "finished", "error", "pre_cancel", "wait_app_run",
|
||||
];
|
||||
|
||||
/** 字节 → 小写 hex(native 的 hex_decode 两种大小写都吃,这里与 WiFi 上传的字节流口径无关)。 */
|
||||
function bytesToHexLower(u8) {
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 电机 OTA(EtherCAT 灌写)正在占用 exclusive 总线 —— 外壳 Owner 与本页的
|
||||
* motor/start 自愈都读同一个标志,判据只在这一处。
|
||||
* 写成 typeof 守卫是因为 scripts/test_motor_transport.mjs 在 Node 里直接 import
|
||||
* 本模块,那里没有 window,裸读会抛 ReferenceError 把整轮契约自测打断。 */
|
||||
function motorOtaEcatBusy() {
|
||||
return (typeof window !== "undefined") && !!window.motorOtaEcatBusy;
|
||||
}
|
||||
function setMotorOtaEcatBusy(v) {
|
||||
if (typeof window !== "undefined") window.motorOtaEcatBusy = !!v;
|
||||
}
|
||||
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
|
||||
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
|
||||
const MOTOR_SLOT_COUNT = 27;
|
||||
@@ -367,6 +392,13 @@ export function createEcatTransport(ctx) {
|
||||
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
/* 电机 OTA 灌写中:native worker 正独占总线(g_bus_lock)并停着 PDO 循环。
|
||||
* 这时候再打 motor/start 只会在锁上排队,排到时把刚恢复的循环又停一遍,
|
||||
* 表现是刷写结束瞬间链路「假掉线」。判据只在这一处挡,外壳 Owner 那道门
|
||||
* (device-link 的 bootstrapEcatBus)读同一个 window.motorOtaEcatBusy。 */
|
||||
if (motorOtaEcatBusy() && !reqPeriodMs) {
|
||||
return { ok: true, period_ms: periodMs, spin: spinOn, motor_ota_busy: true };
|
||||
}
|
||||
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
||||
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
||||
if (!reqPeriodMs) {
|
||||
@@ -394,20 +426,19 @@ export function createEcatTransport(ctx) {
|
||||
localStorage.setItem(LS_PERIOD_MIG, "1");
|
||||
}
|
||||
const v = parseFloat(localStorage.getItem(LS_PERIOD));
|
||||
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
|
||||
if (v > 0 && v !== 1) body.period_ms = v;
|
||||
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
|
||||
if (v > 0 && v >= 0.125 && v <= 4) body.period_ms = v;
|
||||
else body.period_ms = 0.25;
|
||||
} catch (_) { body.period_ms = 0.25; }
|
||||
}
|
||||
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
|
||||
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
|
||||
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
|
||||
/* PDO 周期与 DC SYNC0 同值:周期可随时热改(0.125–4ms),
|
||||
* 不一致时 GD32 processOutput 消费会拖慢,所以启动时强制对齐。 */
|
||||
{
|
||||
let per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
|
||||
if (per < 0.125) per = 0.25;
|
||||
if (per > 4) per = 4;
|
||||
body.period_ms = per;
|
||||
body.dc_cycle_us = Math.round(per * 1000);
|
||||
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
|
||||
body.dc_shift_us = 0;
|
||||
}
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
@@ -571,8 +602,15 @@ export function createEcatTransport(ctx) {
|
||||
return {
|
||||
id: "ecat",
|
||||
label: "EtherCAT",
|
||||
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
|
||||
supportsOta: false,
|
||||
/** 电机 OTA:走 CoE 邮箱把固件灌进 GD32 暂存区,中继引擎与 WiFi 链路同一个
|
||||
* (见下 ota* 四个钩子与 DCU_APP comm/ecat_motor_ota.cpp)。 */
|
||||
supportsOta: true,
|
||||
/** 进 BOOT 帧(0x000N0000)由 GD32 在**已加锁**的通道上自己发,上位机不要再发一遍:
|
||||
* 这条链路的运动帧和它挤同一批 CAN 邮箱,先解锁让上位机发帧等于把窗口留给运动帧。 */
|
||||
otaHostSendsBootFrame: false,
|
||||
/** 完整性口径(如实告诉 UI,不冒充 SHA-256 那条):
|
||||
* crc32-verify-in-slave = 分块 CRC32 + 整镜像 CRC32 都在从站核,核过才让电机擦除。 */
|
||||
otaIntegrity: "crc32-verify-in-slave",
|
||||
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
|
||||
supportsPeriodSync: false,
|
||||
|
||||
@@ -740,7 +778,7 @@ export function createEcatTransport(ctx) {
|
||||
err: linkOk ? b.drop : null,
|
||||
tec: null, // 过程数据里没有
|
||||
rec: null, // 过程数据里没有
|
||||
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
|
||||
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : (b.tx_result === 0xE6 ? 32 : 0)) : null,
|
||||
});
|
||||
}
|
||||
return { channels };
|
||||
@@ -891,14 +929,14 @@ export function createEcatTransport(ctx) {
|
||||
},
|
||||
|
||||
/**
|
||||
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
|
||||
* 热换 PDO 周期(毫秒)。合法区间 0.125–4ms(与 UI 档位一致)。
|
||||
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
|
||||
*/
|
||||
async setPdoPeriod(ms) {
|
||||
let v = Number(ms);
|
||||
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
|
||||
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
|
||||
if (v < 0.0625) v = 0.25;
|
||||
if (v < 0.125) v = 0.125;
|
||||
if (v > 4) v = 4;
|
||||
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
|
||||
if (typeof r.period_ms === "number") periodMs = r.period_ms;
|
||||
else periodMs = v;
|
||||
@@ -907,6 +945,101 @@ export function createEcatTransport(ctx) {
|
||||
return periodMs;
|
||||
},
|
||||
|
||||
/* ── 电机 OTA(EtherCAT 邮箱进料)────────────────────────────────
|
||||
* 通路:这里 → /ecat/api/motor/ota(或桌面壳 pywebview 桥)→ ecat_native 的
|
||||
* worker → CoE SDO 0x2511 数据窗 / 0x2510 会话 → GD32 48KB SRAM 暂存
|
||||
* → **从站先核整镜像 CRC32** → 通道锁 + GD32 自己发 0x000N0000 进 BOOT
|
||||
* → 既有 MotorOtaRelay 停等(AES、单发、重发、0AAC 运行确认一个都没换)。
|
||||
* 换的只是「明文怎么进 GD32」:WiFi 那条是 ESP32 经 SPI 0xD1 喂,
|
||||
* 这条是主站经邮箱喂,所以纯网线(ESP32 HTTP 不可达)也能刷电机。
|
||||
*
|
||||
* 刷写期间 native worker 独占总线并停掉 PDO 循环 ⇒ 各通道运动帧一起暂停,
|
||||
* 与 WiFi 模式「OTA 期间请勿操作电机」等效。进度读的是 worker 发布的 volatile
|
||||
* 全局(op=status 不碰总线),所以轮询频率再高也不会干扰灌写。
|
||||
*/
|
||||
|
||||
/** 一次「上传尝试」:把整份镜像推到主站缓冲。
|
||||
* WiFi 那条在这步做 ESP32 flash 回读 SHA-256 比对;这条没有对端可回读,
|
||||
* shaHex 参数用不上(刻意不假装比过),真校验在从站的启动前一步。
|
||||
* @returns {Promise<{ok:boolean, note:string}>} */
|
||||
async otaUploadAttempt(bytes, shaHex, opts) {
|
||||
const ch = (opts && opts.ch) | 0;
|
||||
const node = (opts && opts.node) | 0;
|
||||
const size = bytes.length;
|
||||
unwrap(await api("motor/ota", { op: "open", size, ch, node }, 8000), "开电机灌写会话");
|
||||
/* push 受 native 请求体 65535B 限制:16KB 二进制 = 32768 hex 字符,余量充足 */
|
||||
const STEP = 16384;
|
||||
for (let off = 0; off < size; off += STEP) {
|
||||
const part = bytes.subarray(off, Math.min(off + STEP, size));
|
||||
unwrap(await api("motor/ota", { op: "push", hex: bytesToHexLower(part) }, 15000),
|
||||
"推送镜像分片");
|
||||
}
|
||||
const st = unwrap(await api("motor/ota", { op: "status" }, 5000), "读灌写状态");
|
||||
const recv = Number(st.srv_recv) || 0;
|
||||
return {
|
||||
ok: recv === size,
|
||||
note: recv === size
|
||||
? `主站已持有 ${size} B(分块 CRC32 已逐块核对,整镜像 CRC 由从站在电机进 BOOT 前核)`
|
||||
: `主站只收到 ${recv}/${size} B`,
|
||||
};
|
||||
},
|
||||
|
||||
/** 起会话:worker 后台跑(灌块 → 从站核 CRC → 锁通道 → 进 BOOT → 停等传输)。
|
||||
* 这一步只负责「派活」,成败由 otaStatus 的终态给结论。 */
|
||||
async otaStartRelay() {
|
||||
const r = unwrap(await api("motor/ota", { op: "run" }, 8000), "启动电机灌写");
|
||||
setMotorOtaEcatBusy(true); /* 挡住外壳 Owner 与本页的 motor/start 重拉 */
|
||||
return { ok: !!r.ok };
|
||||
},
|
||||
|
||||
/** 进度:把 native 的 stage + 从站 relay 数字状态翻译成 WiFi 那套字符串,
|
||||
* 好让 ota.js 的阶段条/日志只用一套判据。 */
|
||||
async otaStatus() {
|
||||
const st = unwrap(await api("motor/ota", { op: "status" }, 4000), "读电机刷写进度");
|
||||
const stage = Number(st.stage) || 0;
|
||||
const relay = Number(st.state) || 0;
|
||||
const errWord = Number(st.err) || 0;
|
||||
const sessErr = (errWord >> 8) & 0xFF;
|
||||
const protoErr = errWord & 0xFF;
|
||||
let state;
|
||||
if (stage === 0) state = "idle";
|
||||
else if (stage === 1) state = "staging"; /* 还在灌从站暂存区,引擎尚未启动 */
|
||||
else if (stage === 2 && relay === 7) state = "finishing";
|
||||
/* relay 已 kFinished 但 worker 还没把过程数据循环与 OP 补回来。
|
||||
* 此刻就报 finished 的话,上层的版本查询会打在「主站在发、从站不在 OP」
|
||||
* 的空档里,白丢一次应答(实测会表现成「OTA 完成但查不到版本」)。 */
|
||||
else if (stage === 2) state = RELAY_STATE_NAMES[relay] || "pre_cancel";
|
||||
else state = st.flash_ok ? "finished" : "error";
|
||||
if (stage === 3) setMotorOtaEcatBusy(false);
|
||||
const reason = String(st.reason || "");
|
||||
return {
|
||||
state,
|
||||
error: state === "error" ? (protoErr || sessErr || 0x01) : protoErr,
|
||||
/* 会话码 0x8x 与电机协议码 0x0x/0x3x 是两种病,必须分开摆出来,
|
||||
* 否则「从站把料挡了」会被读成「电机报错」。 */
|
||||
session_error: sessErr,
|
||||
packets_acked: Number(st.acked) || 0,
|
||||
total_packets: Number(st.pk_total) || 0,
|
||||
fw_size: Number(st.claimed) || 0,
|
||||
stage,
|
||||
note: reason,
|
||||
};
|
||||
},
|
||||
|
||||
/** 取消:请 worker 下一拍写 0x2510:01=3,从站发 CAN 取消帧并释放通道锁。
|
||||
* 等 native 报 running=false 才放开 motorOtaEcatBusy,避免外壳 Owner 在
|
||||
* 会话还挂着的时候抢去 motor/start。 */
|
||||
async otaCancel() {
|
||||
const r = await api("motor/ota", { op: "cancel" }, 5000).catch(() => null);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const st = await api("motor/ota", { op: "status" }, 3000).catch(() => null);
|
||||
if (!st || !st.running) break;
|
||||
await new Promise((res) => setTimeout(res, 400));
|
||||
}
|
||||
setMotorOtaEcatBusy(false);
|
||||
return { ok: !!(r && r.ok) };
|
||||
},
|
||||
|
||||
/** 诊断用(控制台 motorDbg.transport)。 */
|
||||
debug() {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user