2026-09-04 12:18:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
|
|
|
|
|
|
*
|
2026-09-20 22:58:00 +08:00
|
|
|
|
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库)。
|
|
|
|
|
|
* 控制面双路径(2026-09-23):
|
|
|
|
|
|
* - 桌面 APP(esp32_app.vbs / --window):window.top.pywebview.api.ecat_api
|
|
|
|
|
|
* → GUI 进程裸 socket 直连 native(desktop-bridge.js),不经浏览器 HTTP;
|
|
|
|
|
|
* - 网页/浏览器:同源 /ecat/api/* 反向代理(见 esp32_server.py)。
|
|
|
|
|
|
* 无论哪条路径,真正上总线的 EtherCAT PDO 都由 native 后台循环维持。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*
|
|
|
|
|
|
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 1. 服务没起 → 代理侧 _ecat_autostart() 拉起 tools/ecat\build_native\ecat_native.exe(已有实例只复用)
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* 2. 网卡没开 → ensureOpen() 调 /api/open(网卡子串存 localStorage dcu_ecat_adapter)
|
|
|
|
|
|
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
|
|
|
|
|
|
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
|
|
|
|
|
|
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
|
2026-09-20 22:58:00 +08:00
|
|
|
|
* 探测自身是**幂等**的:native/health 显示 bus_open+loop 时直接复用,
|
|
|
|
|
|
* 不再打 motor/start(那条路由会先 loop_stop,热路径假超时 / 楔死风险)。
|
|
|
|
|
|
* probe 超时后另有 healthCheck 只读复核,避免「实际已连上却报未连」。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
|
|
|
|
|
|
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
|
|
|
|
|
|
* 三处形状差异必须在这里补平,否则上层就得为两种传输各写一遍:
|
|
|
|
|
|
*
|
|
|
|
|
|
* 1. **config_data**:WiFi 的 /motor/state 每个电机自带 24B CONFIG 回报;
|
|
|
|
|
|
* EtherCAT 状态窗口只有 32B STATUS,CONFIG(0x000N0101) 是当普通帧从
|
|
|
|
|
|
* 批量接收区回来的。故这里累积 configs[node],回填到 motors[].config_data。
|
|
|
|
|
|
* 2. **版本**:WiFi 由 GD32 解析后经 0xC6 缓存到 /motor/version;EtherCAT
|
|
|
|
|
|
* 没有这个缓存,版本应答(0x000N00FF)同样从批量接收区来,这里自建缓存并
|
|
|
|
|
|
* 维护 seq,让 main.js 那套"按 seq 增长判新应答"的逻辑不用改。
|
|
|
|
|
|
* 3. **节点 ID 应答**:WiFi 走 SPI 请求-应答,帧结果里直接带 recv;
|
|
|
|
|
|
* EtherCAT 是异步的,应答稍后才到,这里存起来供上层取。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 另外两处**不能假装有**的东西,宁可报 null 让 UI 显示 "--":
|
|
|
|
|
|
* - 每通道 TEC/REC/err:过程数据里根本没有(bridge 只有全局计数),
|
|
|
|
|
|
* 编 0 会让"总线正常"和"没这个数据"看起来一样,违背监控 UI 的约定。
|
|
|
|
|
|
* - OTA:EtherCAT 侧没有固件上传 + GD32 relay 链路,supportsOta=false。
|
|
|
|
|
|
*/
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
2026-09-12 20:10:47 +08:00
|
|
|
|
/* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的
|
|
|
|
|
|
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
|
|
|
|
|
|
import { staleLimitMs } from "./refresh-rate.js";
|
2026-09-16 12:12:24 +08:00
|
|
|
|
import { deviceLink } from "../common/device-link.js";
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/* 总线"能不能动"的判据只有一份,放在 ecat-bus.js(与 device-link 同源、同样不带
|
|
|
|
|
|
* ?v= ⇒ 浏览器按同一 URL 去重,Owner 与本模块共用同一个模块实例与同一份预算)。 */
|
|
|
|
|
|
import { proveEcatLoop, repairEcatDc, noteEcatLoopUp } from "../common/ecat-bus.js";
|
2026-09-20 22:58:00 +08:00
|
|
|
|
import {
|
|
|
|
|
|
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
2026-09-21 02:11:49 +08:00
|
|
|
|
desktopEcatSendRaw, hasDesktopFastSend,
|
2026-09-21 03:04:09 +08:00
|
|
|
|
} from "../common/desktop-bridge.js?v=20260923i";
|
2026-09-12 20:10:47 +08:00
|
|
|
|
|
|
|
|
|
|
/** 单个 PDO 周期回传的状态槽数。
|
|
|
|
|
|
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
|
|
|
|
|
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
|
|
|
|
|
|
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
|
|
|
|
|
|
const MOT_SLOTS_PER_PDO = 9;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
2026-09-12 20:10:47 +08:00
|
|
|
|
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
|
|
|
|
|
|
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const MOTOR_SLOT_COUNT = 27;
|
2026-09-12 20:10:47 +08:00
|
|
|
|
/** 扫完全表所需周期数。1.6.21 密排下恒 1(一拍整轮);旧滑窗为 ⌈27/5⌉=6。
|
|
|
|
|
|
* 显示"数据多新"请用快照里的 slot_period_ms。 */
|
|
|
|
|
|
const MOTOR_SCAN_CYCLES = 1;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-12 20:10:47 +08:00
|
|
|
|
* 槽 seq 判活阈值:**不写死** —— 随该槽实测 seq 变化间隔自适应
|
|
|
|
|
|
* clamp(3×EWMA, 2s, 30s),见 refresh-rate.js 的 staleLimitMs()。
|
|
|
|
|
|
* 为什么需要判活:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
|
|
|
|
|
|
* 只能靠 seq 是否还在涨来判活。为什么必须自适应:电机 CAN 回报率可变,
|
|
|
|
|
|
* 回报周期调到秒级后,固定 2s 会在两次正常回报之间把正常电机判离线
|
|
|
|
|
|
* (LED 闪烁、波形画虚线、面板切离线覆盖层);下限 2s 仍兜底快周期
|
|
|
|
|
|
* (要大于"扫完全表 + 一点抖动",1.6.15 滑窗时代 period 10ms × 6 周期 = 60ms)。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
|
2026-09-05 03:56:52 +08:00
|
|
|
|
function pcoreList(mask) {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const out = [];
|
|
|
|
|
|
let m = mask >>> 0;
|
|
|
|
|
|
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* 高 32 位(>31 的核,本机 P 核在 0..15,但大核数机器会越过 31) */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
let hi = Math.floor(mask / 4294967296);
|
|
|
|
|
|
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
|
|
|
|
|
|
return out.sort((a, b) => a - b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/** 连续核号压成区间:[0..15] → "0-15"。状态栏只有一行,
|
|
|
|
|
|
* 核号全列出来会把从站名挤掉,而区间信息量一样。 */
|
|
|
|
|
|
function pcoreRanges(list) {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!list.length) return "";
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
let s = list[0], p = list[0];
|
|
|
|
|
|
for (let i = 1; i <= list.length; i++) {
|
|
|
|
|
|
if (i < list.length && list[i] === p + 1) { p = list[i]; continue; }
|
|
|
|
|
|
parts.push(s === p ? String(s) : `${s}-${p}`);
|
|
|
|
|
|
if (i < list.length) { s = p = list[i]; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts.join(",");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/** 周期显示:<1ms 用 µs("250µs"),否则 ms("0.25ms" 这种写法没人读)。
|
|
|
|
|
|
* 与后端 _fmt_period 保持一致,两边显示口径不能分叉。 */
|
|
|
|
|
|
function fmtPeriod(ms) {
|
|
|
|
|
|
if (!(ms > 0)) return "";
|
|
|
|
|
|
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms}ms`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/** @description 创建 EtherCAT 传输实例。 */
|
|
|
|
|
|
export function createEcatTransport(ctx) {
|
|
|
|
|
|
const { recordSlow, isTransient } = ctx;
|
|
|
|
|
|
|
2026-09-16 12:12:24 +08:00
|
|
|
|
/* 网卡匹配子串:统一从 ConnectionHub 读写(dcu_ecat_adapter),与 OTA/lab 页共用。 */
|
|
|
|
|
|
let adapterSubstr = deviceLink.getEcatAdapter();
|
2026-09-04 12:18:06 +08:00
|
|
|
|
|
|
|
|
|
|
/* ── 传输内部累积状态 ──
|
|
|
|
|
|
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
|
|
|
|
|
|
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const versions = new Map(); // node -> {node, seq, version, at} 供 fetchVersionNode
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* xrx 变更指纹:段3 只有「最近一帧」,内容不变时不要重复 dispatch,
|
|
|
|
|
|
* 否则每拍都把旧版本 seq+1,查询判定会失真。 */
|
|
|
|
|
|
let lastXrxKey = "";
|
2026-09-12 20:10:47 +08:00
|
|
|
|
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
|
2026-09-04 12:18:06 +08:00
|
|
|
|
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
|
|
|
|
|
|
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
|
|
|
|
|
|
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 窗口轮到它才更新。后端的窗口自适应会把窗口收缩到只盖有主的槽,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
|
|
|
|
|
|
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
|
|
|
|
|
|
let slotPeriodMs = 0;
|
|
|
|
|
|
/* 节拍源是否为自旋(来自 motor/start 回报与快照)。
|
|
|
|
|
|
* 必须显示出来:自旋能把 PDO 周期跑准(实测抖动 0.000ms),代价是**吃满一个
|
|
|
|
|
|
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
|
|
|
|
|
|
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
|
|
|
|
|
|
let spinOn = false;
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* 自旋线程被钉到的逻辑处理器位掩码(快照 pcore_mask)。0 = 没钉(对称 CPU
|
|
|
|
|
|
* 或探测失败)。把落在哪些核显示出来,用户才知道自旋占的是哪些核。 */
|
|
|
|
|
|
let pcoreMask = 0;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const LS_SPIN = "dcu_ecat_spin";
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* 保存的 PDO 周期(ms,允许亚毫秒 float,如 0.25=4kHz)。用户把周期调到
|
|
|
|
|
|
* 内核级档位后存这儿,后端重启、循环重开时原样带回 —— 用户要的
|
2026-09-05 19:58:45 +08:00
|
|
|
|
* "后续随时可用"不该每次手敲一遍。恢复默认 = 删掉这个键,跟随后端默认。 */
|
2026-09-05 03:56:52 +08:00
|
|
|
|
const LS_PERIOD = "dcu_ecat_period_ms";
|
2026-09-05 19:58:45 +08:00
|
|
|
|
/* LS_PERIOD 的一次性迁移标记:Python 时代默认 4ms 曾被存进来(检查脚本
|
|
|
|
|
|
* browser_check_period.mjs 收尾就写 "4"),native 后端默认已改小,
|
|
|
|
|
|
* 存量 "4" 会在每次 ensureStarted 把总线压回旧默认 4ms。只删一次,之后
|
|
|
|
|
|
* 再设 4 视为用户显式选择,原样保留。 */
|
|
|
|
|
|
const LS_PERIOD_MIG = "dcu_ecat_period_mig";
|
2026-09-04 12:18:06 +08:00
|
|
|
|
let started = false; // 后台循环是否已启动
|
|
|
|
|
|
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* POST 到 EtherCAT 后端。双路径:
|
|
|
|
|
|
* 同源 HTTP /ecat/api/*(esp32_server 服务子进程代理)—— 热路径优先;
|
|
|
|
|
|
* APP 桥 window.top.pywebview.api.ecat_api —— 低频控制面,或 HTTP 失败时兜底。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么热路径(motor/state、motor/rx)不再优先走桥:pywebview WebView2
|
|
|
|
|
|
* 每次 JS→Python 往返实测把本机 ~0.9ms 的 native 快照抬到约 20ms/拍,
|
|
|
|
|
|
* 6ms 刷新档被按在 ~50Hz;HTTP 代理 state+rx 串行 p50≈2.9ms(≈350Hz)。
|
|
|
|
|
|
* 桥仍保留给 send/status/probe 等低频路由,以及页面不在代理源上时的兜底。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
/* 热路径:界面每拍轮询的路由。这些走 HTTP,不进 pywebview 桥。 */
|
|
|
|
|
|
const HOT_HTTP_PATHS = new Set(["motor/state", "motor/rx"]);
|
|
|
|
|
|
/** drainRx 最小间隔:6ms 档下每拍都 drain 会把请求量翻倍;CONFIG/版本
|
|
|
|
|
|
* 应答本身不按 166Hz 到达,20ms 足够跟上且显著降低代理/桥压力。 */
|
|
|
|
|
|
const DRAIN_MIN_MS = 20;
|
|
|
|
|
|
let lastDrainAt = 0;
|
|
|
|
|
|
|
|
|
|
|
|
async function callBridge(tag, body, timeoutMs) {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const t0 = performance.now();
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const json = await desktopEcatPost(tag, body || {}, timeoutMs || 4000);
|
|
|
|
|
|
recordSlow("bridge:/" + tag, performance.now() - t0);
|
|
|
|
|
|
return json;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function callHttp(tag, body, timeoutMs) {
|
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
|
const url = "/ecat/api/" + tag;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
let resp;
|
|
|
|
|
|
try {
|
|
|
|
|
|
resp = await fetch(url, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify(body || {}),
|
|
|
|
|
|
signal: AbortSignal.timeout(timeoutMs || 4000),
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
|
|
|
|
err.transient = isTransient(err);
|
|
|
|
|
|
recordSlow(url, performance.now() - t0, err.message);
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
recordSlow(url, performance.now() - t0);
|
|
|
|
|
|
const text = await resp.text();
|
|
|
|
|
|
let json = {};
|
|
|
|
|
|
try { json = text ? JSON.parse(text) : {}; } catch (e) {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const err = new Error("EtherCAT 服务响应非合法 JSON");
|
|
|
|
|
|
err.transient = true; /* 代理半截响应:下一拍重试即可,勿累计断线 */
|
|
|
|
|
|
throw err;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
const err = new Error(
|
|
|
|
|
|
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
|
|
|
|
|
|
err.status = resp.status;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return json;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
async function api(path, body, timeoutMs, opts) {
|
|
|
|
|
|
const tag = String(path || "").replace(/^\/+/, "").split("?", 1)[0];
|
|
|
|
|
|
const preferHttp = !!(opts && opts.preferHttp) || HOT_HTTP_PATHS.has(tag);
|
|
|
|
|
|
const hasBridge = !!getDesktopEcatApi();
|
|
|
|
|
|
|
|
|
|
|
|
/* 热路径:HTTP 优先;失败再落桥(代理子进程挂了仍能用)。
|
|
|
|
|
|
* 低频路径:APP 壳里仍优先桥(send 等走 GUI 直连 native 更稳)。 */
|
|
|
|
|
|
if (preferHttp) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await callHttp(tag, body, timeoutMs);
|
|
|
|
|
|
} catch (eHttp) {
|
|
|
|
|
|
if (!hasBridge) throw eHttp;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await callBridge(tag, body, timeoutMs);
|
|
|
|
|
|
} catch (eBridge) {
|
|
|
|
|
|
/* 两次都失败:优先抛 HTTP 错(与 isTransient 口径一致) */
|
|
|
|
|
|
throw eHttp;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasBridge) {
|
|
|
|
|
|
const tBridge = performance.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await callBridge(tag, body, timeoutMs);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
|
|
|
|
if (!err.transient && !/desktop bridge unavailable/i.test(err.message || "")) {
|
|
|
|
|
|
recordSlow("bridge:/" + tag, performance.now() - tBridge, err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 落到 HTTP 兜底 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return callHttp(tag, body, timeoutMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 02:11:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ECAT 发送统一入口。
|
|
|
|
|
|
* 桌面桥且 ecat_send_raw 可用:**只走 ESF1**,失败直接返回 ok:false,
|
|
|
|
|
|
* **禁止**回落 HTTP motor/send(2026-09-23 堵死慢发,防误走 JSON 路径)。
|
|
|
|
|
|
* 无桌面桥(浏览器标签):仍走 api("motor/send") HTTP。
|
|
|
|
|
|
* payload = {frames:[{ch,id,dlc,data}]}。
|
|
|
|
|
|
*/
|
|
|
|
|
|
let lastFastSendPath = "";
|
|
|
|
|
|
async function sendViaFastOrJson(payload) {
|
|
|
|
|
|
if (hasDesktopFastSend()) {
|
|
|
|
|
|
lastFastSendPath = "fast_tcp";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await desktopEcatSendRaw(payload.frames || [], 2000);
|
|
|
|
|
|
if (r && r.ok === true) {
|
|
|
|
|
|
if (r.path) lastFastSendPath = r.path;
|
|
|
|
|
|
return r;
|
|
|
|
|
|
}
|
|
|
|
|
|
const msg = (r && r.log && r.log.length)
|
|
|
|
|
|
? r.log.join(" / ")
|
|
|
|
|
|
: "二进制快发失败(已禁用 HTTP/JSON 回落)";
|
|
|
|
|
|
recordSlow("fast-send", 0, msg);
|
|
|
|
|
|
return { ok: false, queued: 0, backlog: 0, path: "fast_tcp", log: [msg] };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
|
|
|
|
recordSlow("fast-send", 0, err.message);
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: false, queued: 0, backlog: 0, path: "fast_tcp",
|
|
|
|
|
|
log: [err.message || "二进制快发异常"],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
lastFastSendPath = "json";
|
|
|
|
|
|
return api("motor/send", payload, 6000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
function unwrap(json, what) {
|
|
|
|
|
|
if (!json || json.ok !== true) {
|
|
|
|
|
|
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
|
|
|
|
|
|
const err = new Error(msg);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* 可自愈业务态:循环未起/未连接/忙/排队 —— ensureStarted/probe 会重开。
|
|
|
|
|
|
* 把这些算硬失败会在高压毛刺下 5 拍内把页面判成断线(实测踩过)。 */
|
|
|
|
|
|
err.transient = /未启动|未连接|周期异常|排队|busy|暂时|稍后|重试|超时|timed?\s*out|bridge|序列化/i.test(msg);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return json;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-21 03:04:09 +08:00
|
|
|
|
* 确保网卡已打开(从站已被发现)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
|
|
|
|
|
|
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
|
|
|
|
|
|
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
|
|
|
|
|
|
* 用户还是得开另一个页面点两下才能用。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 幂等:后端的 open 路由会先 close 掉旧 master 再重开,
|
|
|
|
|
|
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async function ensureOpen() {
|
2026-09-21 03:04:09 +08:00
|
|
|
|
const st = await api("status", {}, 6000);
|
|
|
|
|
|
if (st && st.ok === true) return st; // 已连接
|
|
|
|
|
|
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
|
|
|
|
|
|
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
|
|
|
|
|
const r = await api("open", { substr: adapterSubstr }, 20000);
|
|
|
|
|
|
if (!r || r.ok !== true) {
|
|
|
|
|
|
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
|
|
|
|
|
|
const err = new Error(msg);
|
|
|
|
|
|
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
|
|
|
|
|
|
* 标成 transient 会让页面一直转圈却不给红灯提示。
|
|
|
|
|
|
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
|
|
|
|
|
|
err.transient = false;
|
|
|
|
|
|
throw err;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
2026-09-21 03:04:09 +08:00
|
|
|
|
return r;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
|
2026-09-21 03:04:09 +08:00
|
|
|
|
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
|
|
|
|
|
|
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
|
|
|
|
|
|
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
|
2026-09-20 22:58:00 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async function readHealth() {
|
|
|
|
|
|
const h = await api("native/health", {}, 2500);
|
|
|
|
|
|
if (!h || h.ok !== true) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
busOpen: !!h.bus_open,
|
|
|
|
|
|
op: !!h.op,
|
|
|
|
|
|
loop: !!h.loop,
|
|
|
|
|
|
cycles: Number(h.cycles) || 0,
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/* 原始回包:证明函数与界面要读 dc/period_ms/guard/recovering/pm_hold 这些
|
|
|
|
|
|
* 只存在于 native 侧的字段。再抄一遍字段名 = 迟早与 native 漂移,
|
|
|
|
|
|
* 而漂移的方向通常是"页面以为一切正常"。 */
|
|
|
|
|
|
raw: h,
|
2026-09-20 22:58:00 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。
|
|
|
|
|
|
* 判据是 ecat-bus.proveEcatLoop 的 'up'(三态里只有 up 算健康)。 */
|
2026-09-21 03:04:09 +08:00
|
|
|
|
async function isBusHealthy() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const h = await readHealth();
|
2026-09-22 00:30:33 +08:00
|
|
|
|
return proveEcatLoop(h && h.raw) === "up";
|
2026-09-21 03:04:09 +08:00
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/** 从 motor/state 快照同步周期/自旋/钉核,供状态栏在跳过 start 后仍显示正确。 */
|
|
|
|
|
|
function syncFromSnapshot(snap) {
|
|
|
|
|
|
if (!snap) return;
|
|
|
|
|
|
if (snap.period_ms > 0) periodMs = snap.period_ms;
|
|
|
|
|
|
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
|
|
|
|
|
|
if (typeof snap.spin === "boolean") spinOn = snap.spin;
|
|
|
|
|
|
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
|
|
|
|
|
|
lastSnapshot = snap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 03:04:09 +08:00
|
|
|
|
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
async function ensureStarted(reqPeriodMs) {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 电机 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 };
|
|
|
|
|
|
}
|
2026-09-21 03:04:09 +08:00
|
|
|
|
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
|
|
|
|
|
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (!reqPeriodMs) {
|
2026-09-21 03:04:09 +08:00
|
|
|
|
if (await isBusHealthy()) {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
started = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
|
|
|
|
|
|
syncFromSnapshot(snap);
|
|
|
|
|
|
return snap;
|
|
|
|
|
|
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
|
|
|
|
|
|
return { ok: true, period_ms: periodMs, spin: spinOn };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-21 03:04:09 +08:00
|
|
|
|
const body = {};
|
|
|
|
|
|
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
|
|
|
|
|
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
|
|
|
|
|
|
* 没显式给时带上 localStorage 里的上次取值(内核级 250us 档位能跨重启存活)。 */
|
|
|
|
|
|
if (reqPeriodMs) body.period_ms = reqPeriodMs;
|
|
|
|
|
|
else {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!localStorage.getItem(LS_PERIOD_MIG)) {
|
|
|
|
|
|
if (parseFloat(localStorage.getItem(LS_PERIOD)) === 4) {
|
|
|
|
|
|
localStorage.removeItem(LS_PERIOD);
|
|
|
|
|
|
}
|
|
|
|
|
|
localStorage.setItem(LS_PERIOD_MIG, "1");
|
|
|
|
|
|
}
|
|
|
|
|
|
const v = parseFloat(localStorage.getItem(LS_PERIOD));
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
|
|
|
|
|
|
if (v > 0 && v >= 0.125 && v <= 4) body.period_ms = v;
|
2026-09-21 03:04:09 +08:00
|
|
|
|
else body.period_ms = 0.25;
|
|
|
|
|
|
} catch (_) { body.period_ms = 0.25; }
|
|
|
|
|
|
}
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* PDO 周期与 DC SYNC0 同值:周期可随时热改(0.125–4ms),
|
|
|
|
|
|
* 不一致时 GD32 processOutput 消费会拖慢,所以启动时强制对齐。 */
|
2026-09-21 03:04:09 +08:00
|
|
|
|
{
|
|
|
|
|
|
let per = Number(body.period_ms) || 0.25;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
if (per < 0.125) per = 0.25;
|
|
|
|
|
|
if (per > 4) per = 4;
|
2026-09-21 03:04:09 +08:00
|
|
|
|
body.period_ms = per;
|
|
|
|
|
|
body.dc_cycle_us = Math.round(per * 1000);
|
|
|
|
|
|
body.dc_shift_us = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
|
|
|
|
|
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
|
|
|
|
|
|
let spinPref = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const v = localStorage.getItem(LS_SPIN);
|
|
|
|
|
|
if (v === "0" || v === "1") spinPref = (v === "1");
|
|
|
|
|
|
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
|
|
|
|
|
|
if (spinPref !== null) body.spin = spinPref;
|
|
|
|
|
|
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
|
|
|
|
|
|
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
|
|
|
|
|
|
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
|
|
|
|
|
|
periodMs = r.period_ms || periodMs;
|
|
|
|
|
|
if (typeof r.spin === "boolean") spinOn = r.spin;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
started = true;
|
2026-09-21 03:04:09 +08:00
|
|
|
|
return r;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
function xrxChangeKey(x) {
|
|
|
|
|
|
if (!x || !x.data) return "";
|
|
|
|
|
|
return x.id + "|" + x.dlc + "|" + x.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。
|
|
|
|
|
|
* fromXrx=true 表示段3快照复读,调用方须先做内容变更判断。 */
|
|
|
|
|
|
function dispatchFrame(id, dlc, dataHex, fromXrx) {
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!dataHex) return;
|
|
|
|
|
|
const node = (id >> 16) & 0xF;
|
|
|
|
|
|
const sub = id & 0xFFFF;
|
|
|
|
|
|
if (sub === 0x0101) {
|
|
|
|
|
|
configs.set(node, dataHex); // CONFIG 目标值回报
|
|
|
|
|
|
} else if (sub === 0x00FF) {
|
|
|
|
|
|
const prev = versions.get(node);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* drainRx / xrx 内容变化 = 新到达或新快照,写入 versions 供查询提取。
|
|
|
|
|
|
* 同内容 xrx 复读:只刷新 at,不 bump seq(避免 seq 虚高)。 */
|
|
|
|
|
|
if (fromXrx && prev && prev.version === dataHex && prev.seq != null) {
|
|
|
|
|
|
versions.set(node, {
|
|
|
|
|
|
node,
|
|
|
|
|
|
seq: prev.seq,
|
|
|
|
|
|
version: dataHex,
|
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-04 12:18:06 +08:00
|
|
|
|
versions.set(node, {
|
|
|
|
|
|
node,
|
2026-09-20 22:58:00 +08:00
|
|
|
|
seq: (prev && prev.seq != null) ? prev.seq + 1 : 1,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
version: dataHex,
|
2026-09-20 22:58:00 +08:00
|
|
|
|
at: Date.now(),
|
2026-09-04 12:18:06 +08:00
|
|
|
|
});
|
|
|
|
|
|
} else if (sub === 0x0400 || sub === 0x0401) {
|
|
|
|
|
|
nodeIdReports.set(node, dataHex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
|
|
|
|
|
|
async function drainRx() {
|
|
|
|
|
|
const r = await api("motor/rx", {}, 3000);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (!r || !r.frames) return 0;
|
|
|
|
|
|
let n = 0;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
for (const f of r.frames) {
|
|
|
|
|
|
if (!f.valid) continue;
|
|
|
|
|
|
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
|
|
|
|
|
|
if ((f.id & 0xFFFF) === 0x0100) continue;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
dispatchFrame(f.id, f.dlc, f.data, false);
|
|
|
|
|
|
n++;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
return n;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 快照 → WiFi 的 /motor/state 契约。
|
2026-09-12 20:10:47 +08:00
|
|
|
|
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
|
|
|
|
|
|
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
|
2026-09-20 22:58:00 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ★ 快照槽位会「闪没」:dense 窗口 win_count 偶发 3→2(实测 wkc 仍 3/3),
|
|
|
|
|
|
* 电机整只从 motors[] 消失。若直接映射,UI 立刻「节点离线」+波形虚线。
|
|
|
|
|
|
* 这里对**刚见过**的槽做 last-known 保持:缺席但仍落在 staleLimit 窗口内
|
|
|
|
|
|
* → 沿用上一拍 payload 且 online=true;超过窗口才真离线。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const lastSlot = new Map(); // "ch:node" -> {node,ch,dlc,seq,data}
|
|
|
|
|
|
const lastOnlineAt = new Map(); // "ch:node" -> 最近一次判定 online 的时刻
|
|
|
|
|
|
/** 从站 online=false 时的软保持:刚在线过又瞬时掉标志,不立刻翻离线 */
|
|
|
|
|
|
const ONLINE_FALSE_HOLD_MS = 500;
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
function snapshotToMotorState(snap) {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const motors = [];
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const present = new Set();
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
for (const m of (snap.motors || [])) {
|
|
|
|
|
|
const key = m.ch + ":" + m.node;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
present.add(key);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const prev = seqSeen.get(key);
|
|
|
|
|
|
let rx = prev ? prev.rx : 0;
|
|
|
|
|
|
let at = prev ? prev.at : now;
|
2026-09-12 20:10:47 +08:00
|
|
|
|
let iv = prev ? prev.iv : 0;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!prev || prev.seq !== m.seq) {
|
2026-09-12 20:10:47 +08:00
|
|
|
|
if (prev) {
|
|
|
|
|
|
const d = now - prev.at;
|
|
|
|
|
|
iv = iv ? iv + (d - iv) * 0.3 : d;
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
rx = rx + 1;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
at = now;
|
|
|
|
|
|
}
|
2026-09-12 20:10:47 +08:00
|
|
|
|
seqSeen.set(key, { seq: m.seq, at, rx, iv });
|
|
|
|
|
|
const fresh = (now - at) <= staleLimitMs(iv);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
let online = !!m.online && fresh;
|
|
|
|
|
|
if (online) lastOnlineAt.set(key, now);
|
|
|
|
|
|
else if ((now - (lastOnlineAt.get(key) || 0)) < ONLINE_FALSE_HOLD_MS) {
|
|
|
|
|
|
online = true; /* 软保持:标志瞬时 false 不立刻打脸 UI */
|
|
|
|
|
|
}
|
|
|
|
|
|
lastSlot.set(key, {
|
|
|
|
|
|
node: m.node, ch: m.ch, dlc: m.dlc, seq: m.seq, data: m.data,
|
|
|
|
|
|
});
|
2026-09-04 12:18:06 +08:00
|
|
|
|
motors.push({
|
|
|
|
|
|
node: m.node,
|
|
|
|
|
|
ch: m.ch,
|
2026-09-20 22:58:00 +08:00
|
|
|
|
online,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
dlc: m.dlc,
|
|
|
|
|
|
rx_count: rx,
|
|
|
|
|
|
seq: m.seq,
|
|
|
|
|
|
data: m.data,
|
|
|
|
|
|
config_data: configs.get(m.node) || "",
|
|
|
|
|
|
config_seq: 0,
|
|
|
|
|
|
config_rx_count: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
|
|
|
|
|
|
/* 本拍缺席的槽:仍在 stale 窗口内 → last-known 保持在线 */
|
|
|
|
|
|
for (const [key, slot] of lastSlot) {
|
|
|
|
|
|
if (present.has(key)) continue;
|
|
|
|
|
|
const prev = seqSeen.get(key);
|
|
|
|
|
|
if (!prev) continue;
|
|
|
|
|
|
if ((now - prev.at) > staleLimitMs(prev.iv)) continue;
|
|
|
|
|
|
motors.push({
|
|
|
|
|
|
node: slot.node,
|
|
|
|
|
|
ch: slot.ch,
|
|
|
|
|
|
online: true,
|
|
|
|
|
|
dlc: slot.dlc,
|
|
|
|
|
|
rx_count: prev.rx,
|
|
|
|
|
|
seq: prev.seq,
|
|
|
|
|
|
data: slot.data,
|
|
|
|
|
|
config_data: configs.get(slot.node) || "",
|
|
|
|
|
|
config_seq: 0,
|
|
|
|
|
|
config_rx_count: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const head = snap.head || {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
have_data: motors.length > 0,
|
|
|
|
|
|
active_count: head.active || 0,
|
|
|
|
|
|
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
|
|
|
|
|
|
boot_active: !!head.boot_active,
|
|
|
|
|
|
motors,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: "ecat",
|
|
|
|
|
|
label: "EtherCAT",
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/** 电机 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",
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
|
|
|
|
|
|
supportsPeriodSync: false,
|
|
|
|
|
|
|
|
|
|
|
|
sendHint(ch) {
|
2026-09-21 02:11:49 +08:00
|
|
|
|
const fast = lastFastSendPath === "fast_tcp"
|
|
|
|
|
|
? "二进制快发"
|
|
|
|
|
|
: desktopBridgeLabel();
|
|
|
|
|
|
return fast + " · motor/send · CH" + ch +
|
2026-09-20 22:58:00 +08:00
|
|
|
|
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 连接探测 —— 一路自动到能用:
|
|
|
|
|
|
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
|
|
|
|
|
|
* 网卡没开 → ensureOpen() 代替用户点"连接"
|
2026-09-20 22:58:00 +08:00
|
|
|
|
* 循环已在跑 → 直接复用(健康位只读,**不**再打 motor/start)
|
|
|
|
|
|
* 没进 OP / 循环没跑 → ensureStarted() 走 motor/start
|
|
|
|
|
|
* 用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*
|
2026-09-20 22:58:00 +08:00
|
|
|
|
* 只有 /api/status 通不算「从站在线」—— 循环没跑、从站没进 OP 时界面全是
|
|
|
|
|
|
* "--"。probe/healthCheck 只保证主站链路可用(刷写也要能进页面);
|
|
|
|
|
|
* 工具栏在线灯由 main.js 的 isTargetOnline 按 AL/op/STATUS 判定。
|
|
|
|
|
|
*
|
|
|
|
|
|
* **为什么健康时必须跳过 motor/start**:它在 _BUS_EXCLUSIVE 语义下会先
|
|
|
|
|
|
* loop_stop 再核实 OP/重进;外壳 Owner 可能已把总线跑起来,页面每次探测
|
|
|
|
|
|
* 都去停一次循环,既假超时(横幅「signal timed out」)又增加 SSC 楔死风险。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async probe() {
|
|
|
|
|
|
const st = await ensureOpen();
|
|
|
|
|
|
await ensureStarted();
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* 把 native health 的 op 位带给 UI:在线灯要区分「主站栈在跑」和「从站在 OP」 */
|
|
|
|
|
|
try {
|
|
|
|
|
|
const h = await readHealth();
|
|
|
|
|
|
if (st && h && st.__op == null) st.__op = !!h.op;
|
|
|
|
|
|
} catch (_) { /* 健康位读不到时靠 status 的 AL 字段 */ }
|
|
|
|
|
|
return st;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 轻量健康复核(probe 超时后的「其实已经连上了」判定)。
|
|
|
|
|
|
* 只读 native/health + status + motor/state,绝不 motor/start、不动总线。
|
|
|
|
|
|
* 成功返回 status 数据(形状与 probe 一致);不健康则抛错。
|
|
|
|
|
|
* 注意:这里只判断「主站链路可用」,从站是否在线由 UI 的 isTargetOnline
|
|
|
|
|
|
* 按 AL/op/STATUS 再判 —— 刷写场景从站可以停在 PREOP/BOOT 仍需页面可用。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async healthCheck() {
|
|
|
|
|
|
const h = await readHealth();
|
|
|
|
|
|
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
|
|
|
|
|
|
if (!h.loop) throw new Error("PDO 循环未运行");
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/* DC 自愈只能走 ecat-bus.repairEcatDc:它"证明不了配错就不动总线"。
|
|
|
|
|
|
* 以前这里是内联的 dc !== 1 || dcs !== 250 —— 把"回包没有 dc 字段"当成
|
|
|
|
|
|
* "DC 配错",于是一篇错误回包/一份旧 exe 就打出一记 loop_stop 的
|
|
|
|
|
|
* motor/start,而这条路径恰好只在 probe() 超时(已有一记 loop_stop 在飞)后走。 */
|
2026-09-20 22:58:00 +08:00
|
|
|
|
try {
|
2026-09-22 00:30:33 +08:00
|
|
|
|
await repairEcatDc(api, h.raw);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
} catch (_) { /* 自愈失败仍按 health 结果返回 */ }
|
|
|
|
|
|
const st = await api("status", {}, 2500);
|
|
|
|
|
|
if (!st || st.ok !== true) throw new Error("从站状态不可读");
|
|
|
|
|
|
if (st && typeof st === "object") st.__op = !!h.op;
|
|
|
|
|
|
started = true;
|
2026-09-22 00:30:33 +08:00
|
|
|
|
noteEcatLoopUp(); /* 走到这里 health 已证明 loop=1:把自动重启预算交还给下一次故障 */
|
2026-09-20 22:58:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snap = await api("motor/state", {}, 2500);
|
|
|
|
|
|
if (snap && snap.ok === true) syncFromSnapshot(snap);
|
|
|
|
|
|
} catch (_) { /* 快照暂不可读不影响健康判定 */ }
|
2026-09-04 12:18:06 +08:00
|
|
|
|
return st;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** 健康检查:EtherCAT 没有 /system/status,用从站身份/AL 状态代替。 */
|
|
|
|
|
|
async fetchStatus() {
|
|
|
|
|
|
const st = unwrap(await api("status", {}, 6000), "读从站状态");
|
|
|
|
|
|
if (!started) await ensureStarted();
|
|
|
|
|
|
return st;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
describeStatus(data) {
|
|
|
|
|
|
const s = (data && data.slave) || {};
|
|
|
|
|
|
const AL = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
parts.push("AL " + (AL[s.state] || ("0x" + Number(s.state || 0).toString(16))));
|
|
|
|
|
|
if (s.al_status) parts.push("code 0x" + Number(s.al_status).toString(16).toUpperCase().padStart(4, "0"));
|
|
|
|
|
|
if (s.name) parts.push(s.name);
|
|
|
|
|
|
/* PDO 周期 = 总线速度;单槽周期 = 某个电机的状态多久更新一次。
|
|
|
|
|
|
* 两者不等时(电机分散在多个窗口)必须都显示,否则用户会拿 PDO 周期
|
|
|
|
|
|
* 当成数据新鲜度 —— 那正是"看着 4ms 实际 16ms"的谎报。 */
|
|
|
|
|
|
if (periodMs) {
|
|
|
|
|
|
parts.push(slotPeriodMs && slotPeriodMs !== periodMs
|
2026-09-05 03:56:52 +08:00
|
|
|
|
? "PDO " + fmtPeriod(periodMs) + " \u00b7 单槽 " + fmtPeriod(slotPeriodMs)
|
|
|
|
|
|
: "PDO " + fmtPeriod(periodMs));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 钉到 P 核时把核号一起写出来,用户才知道占的是性能核。 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (periodMs && spinOn) {
|
2026-09-05 03:56:52 +08:00
|
|
|
|
const rng = pcoreRanges(pcoreList(pcoreMask));
|
|
|
|
|
|
parts.push(rng ? `⚡ 自旋节拍 · 钉在 P 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return parts;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** EtherCAT 不经 IP 通信(走网卡二层),不回报设备 IP。 */
|
|
|
|
|
|
reportedIp() { return null; },
|
|
|
|
|
|
|
|
|
|
|
|
async fetchMotorState() {
|
|
|
|
|
|
const snap = unwrap(await api("motor/state", {}, 4000), "读电机快照");
|
|
|
|
|
|
lastSnapshot = snap;
|
|
|
|
|
|
/* 单槽刷新周期随在线电机分布变化(窗口自适应),每拍跟着更新 */
|
|
|
|
|
|
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* PDO 周期也可能被别的入口热改过(rate_probe 脚本、另一个标签页),
|
|
|
|
|
|
* 每拍从快照同步,不然状态栏会一直显示本页面记忆里的旧周期。 */
|
|
|
|
|
|
if (snap.period_ms > 0) periodMs = snap.period_ms;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (typeof snap.spin === "boolean") spinOn = snap.spin;
|
2026-09-05 03:56:52 +08:00
|
|
|
|
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* 消费批量接收区。
|
|
|
|
|
|
* queued = 队列深度(可取帧数);count/pending 是本拍 PDO 新增/溢出。
|
|
|
|
|
|
* 三者任一有值才 drain。旧后端无 queued 时,查询等待期靠 drainPendingRx 兜底。 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const rxb = snap.rx_batch;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const needDrain = !!(rxb && (
|
|
|
|
|
|
rxb.count > 0 ||
|
|
|
|
|
|
rxb.pending > 0 ||
|
|
|
|
|
|
(typeof rxb.queued === "number" && rxb.queued > 0)
|
|
|
|
|
|
));
|
|
|
|
|
|
/* 节流 drain:6ms 档下每拍 drain 会把 HTTP 请求量翻倍。
|
|
|
|
|
|
* queued 很深(积压)时强制立刻取,避免版本/CONFIG 应答延迟。 */
|
|
|
|
|
|
const nowDrain = Date.now();
|
|
|
|
|
|
const backlog = (rxb && (rxb.queued || rxb.pending || rxb.count)) || 0;
|
|
|
|
|
|
if (needDrain && (backlog >= 32 || (nowDrain - lastDrainAt) >= DRAIN_MIN_MS)) {
|
|
|
|
|
|
lastDrainAt = nowDrain;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* 段3 xrx:通用长帧快照(CONFIG/版本/节点ID)。
|
|
|
|
|
|
* 内容变化时分派进 configs/versions;查询路径另用 peekVersionXrx
|
|
|
|
|
|
* 直接读 snap,不依赖这里的 lastXrxKey 去重。 */
|
|
|
|
|
|
const xk = xrxChangeKey(snap.xrx);
|
|
|
|
|
|
if (xk) {
|
|
|
|
|
|
if (xk !== lastXrxKey) {
|
|
|
|
|
|
lastXrxKey = xk;
|
|
|
|
|
|
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastXrxKey = "";
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return snapshotToMotorState(snap);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* CAN 反馈(状态栏)。直接复用上一次快照里的 bridge,不再打一次后端:
|
|
|
|
|
|
* 那本来就是同一份过程数据,多打一次只会拿到同样的值。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 逐字段说明为什么很多是 null:过程数据的 bridge 段是**桥全局**计数,
|
|
|
|
|
|
* 没有按通道拆分,也完全没有 TEC/REC。按监控 UI 的约定,
|
|
|
|
|
|
* "通信正常但无此数据" 必须显示 "--",不能编 0 冒充正常。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async fetchFeedback() {
|
|
|
|
|
|
const snap = lastSnapshot;
|
|
|
|
|
|
const b = (snap && snap.bridge) || null;
|
|
|
|
|
|
const linkOk = !!(snap && b && b.valid && snap.wkc === snap.expected_wkc);
|
|
|
|
|
|
const channels = [];
|
|
|
|
|
|
for (let ch = 0; ch < 3; ch++) {
|
|
|
|
|
|
channels.push({
|
|
|
|
|
|
ch,
|
|
|
|
|
|
enabled: true, // 过程数据不回报通道启用状态,不能据此禁止发送
|
|
|
|
|
|
valid: linkOk,
|
|
|
|
|
|
rx: linkOk ? b.rx_count : null,
|
|
|
|
|
|
tx: linkOk ? b.tx_count : null,
|
|
|
|
|
|
err: linkOk ? b.drop : null,
|
|
|
|
|
|
tec: null, // 过程数据里没有
|
|
|
|
|
|
rec: null, // 过程数据里没有
|
2026-10-13 00:48:34 +08:00
|
|
|
|
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : (b.tx_result === 0xE6 ? 32 : 0)) : null,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return { channels };
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发一批帧:一次提交的多帧被后端塞进同一个 PDO 周期的批量发送区,
|
|
|
|
|
|
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
|
|
|
|
|
|
* 逐帧调用会退化成一帧一个周期。
|
|
|
|
|
|
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
|
2026-09-21 02:11:49 +08:00
|
|
|
|
*
|
|
|
|
|
|
* PDO 快发:桌面桥有 ecat_send_raw 时**只走** ESF1 二进制直通,
|
|
|
|
|
|
* 失败 ok:false、不回落 JSON(堵死慢发)。无桥(浏览器)才走 motor/send。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async sendFrames(frames, opts) {
|
|
|
|
|
|
if (!started) await ensureStarted();
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
frames: frames.map((f) => ({
|
|
|
|
|
|
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
2026-09-21 02:11:49 +08:00
|
|
|
|
const r = await sendViaFastOrJson(payload);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!r || r.ok !== true) {
|
|
|
|
|
|
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
|
|
|
|
|
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 后端不做逐帧回执(帧是同一个 seq 沿一次性发出的),
|
|
|
|
|
|
* 统一回 ok;真正的总线级错误在下一拍 bridge.tx_result 里体现。 */
|
|
|
|
|
|
return frames.map(() => ({ status: "ok" }));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** 突发发送:EtherCAT 的批量区天然就是"同一 seq 沿",直接复用 sendFrames。 */
|
|
|
|
|
|
async sendBurst(frames, opts) {
|
|
|
|
|
|
const built = frames.map((f) => ({
|
|
|
|
|
|
id: ((f.node & 0xF) << 16) | (opts.suffix & 0xFFFF),
|
|
|
|
|
|
dlc: opts.dlc,
|
|
|
|
|
|
data: f.data,
|
|
|
|
|
|
ch: f.ch,
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (!started) await ensureStarted();
|
2026-09-21 02:11:49 +08:00
|
|
|
|
const r = await sendViaFastOrJson({ frames: built });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (!r || r.ok !== true) {
|
|
|
|
|
|
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
|
|
|
|
|
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
|
|
|
|
|
}
|
|
|
|
|
|
return built.map(() => ({ status: "ok" }));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** 版本:由批量接收区累积(见模块头注释第 2 点)。 */
|
|
|
|
|
|
async fetchVersionNode(node) {
|
|
|
|
|
|
return versions.get(node) || null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/** 版本查询等待期强制消费 motor/rx(不依赖 rx_batch.count)。 */
|
|
|
|
|
|
drainPendingRx() {
|
|
|
|
|
|
return drainRx();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 直接看 PDO 段3 xrx 是否正带着「本节点」的 0x00FF。
|
|
|
|
|
|
* xrx 是共享快照、不会被 drain 消费;seq 不增长时也能命中。
|
|
|
|
|
|
* 用于破解「xrx 上是别的节点版本 / 同内容不 bump seq」导致的阵发全败。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async peekVersionXrx(node) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const st = await api("motor/state", {}, 2500);
|
|
|
|
|
|
lastSnapshot = st;
|
|
|
|
|
|
if (st && st.period_ms > 0) periodMs = st.period_ms;
|
|
|
|
|
|
const x = st && st.xrx;
|
|
|
|
|
|
if (!x || !x.data) return null;
|
|
|
|
|
|
const id = (x.id >>> 0);
|
|
|
|
|
|
if (((id >> 16) & 0xF) !== node || (id & 0xFFFF) !== 0x00FF) return null;
|
|
|
|
|
|
const prev = versions.get(node);
|
|
|
|
|
|
if (!prev || prev.version !== x.data) {
|
|
|
|
|
|
dispatchFrame(id, x.dlc, x.data, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
const cur = versions.get(node);
|
|
|
|
|
|
return {
|
|
|
|
|
|
node,
|
|
|
|
|
|
seq: cur && cur.seq != null ? cur.seq : (prev && prev.seq != null ? prev.seq : 1),
|
|
|
|
|
|
version: x.data,
|
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
|
src: "xrx",
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
|
|
|
|
|
|
takeNodeIdReport(node) {
|
|
|
|
|
|
const hex = nodeIdReports.get(node);
|
|
|
|
|
|
if (hex) nodeIdReports.delete(node);
|
|
|
|
|
|
return hex || null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** PDO 周期由后台循环维持,与界面刷新率无关,故不实现。 */
|
|
|
|
|
|
async setPeriod() { /* no-op:见 supportsPeriodSync */ },
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切走本传输:**故意不停后台 PDO 循环**。
|
|
|
|
|
|
* 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B;
|
|
|
|
|
|
* 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。
|
2026-09-05 19:58:45 +08:00
|
|
|
|
* 真正该停的时机是"整个 app 关掉"—— 由服务端的 app 租约负责:
|
|
|
|
|
|
* 最后一个 app 窗口断开后 esp32_server 会调 /api/op/stop 停循环并退回 INIT,
|
|
|
|
|
|
* 这里(页面内)什么都不用做。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async teardown() { started = false; lastSnapshot = null; },
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前网卡匹配子串(工具栏输入框回填用)。 */
|
|
|
|
|
|
getAdapter() { return adapterSubstr; },
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 换网卡:立即重开并重进 OP。
|
|
|
|
|
|
* 换网卡意味着当前 master 指向错的网口,留着旧连接毫无意义,
|
|
|
|
|
|
* 所以这里主动 close 让下一次 ensureOpen 走全新的 open。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async setAdapter(substr) {
|
|
|
|
|
|
const v = String(substr || "").trim();
|
|
|
|
|
|
if (!v || v === adapterSubstr) return false;
|
|
|
|
|
|
adapterSubstr = v;
|
2026-09-16 12:12:24 +08:00
|
|
|
|
try { deviceLink.setEcatAdapter(v); } catch (_) {}
|
2026-09-04 12:18:06 +08:00
|
|
|
|
started = false;
|
|
|
|
|
|
lastSnapshot = null;
|
|
|
|
|
|
try { await api("close", {}, 8000); } catch (_) { /* 没连接时无所谓 */ }
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设节拍源:true=自旋(周期跑得准,占满一个核),false=内核睡眠(省 CPU,
|
|
|
|
|
|
* 周期超调约 0.5ms),null=交回后端按周期自动决定。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 必须走 /api/motor/spin 而不是 /api/motor/start:后者在
|
|
|
|
|
|
* _BUS_EXCLUSIVE_PATHS 里,dispatcher 会先停掉 PDO 循环再执行,而停手超过
|
|
|
|
|
|
* 从站那 1000ms 过程数据看门狗就会把它踢出 OP,重进时还可能撞上 SSC 邮箱
|
|
|
|
|
|
* 楔死(实测把一块好板子弄死过两次)。自旋只是循环里读的一个标志,热改即可。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async setSpin(v) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (v === null || v === undefined) localStorage.removeItem(LS_SPIN);
|
|
|
|
|
|
else localStorage.setItem(LS_SPIN, v ? "1" : "0");
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
const body = (v === null || v === undefined) ? {} : { spin: !!v };
|
|
|
|
|
|
const r = unwrap(await api("motor/spin", body, 8000), "切换节拍源");
|
|
|
|
|
|
if (typeof r.spin === "boolean") spinOn = r.spin;
|
|
|
|
|
|
if (r.period_ms) periodMs = r.period_ms;
|
|
|
|
|
|
return spinOn;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/**
|
2026-10-13 00:48:34 +08:00
|
|
|
|
* 热换 PDO 周期(毫秒)。合法区间 0.125–4ms(与 UI 档位一致)。
|
2026-09-20 22:58:00 +08:00
|
|
|
|
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
|
2026-09-05 03:56:52 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async setPdoPeriod(ms) {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
let v = Number(ms);
|
2026-09-05 03:56:52 +08:00
|
|
|
|
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
|
2026-10-13 00:48:34 +08:00
|
|
|
|
if (v < 0.125) v = 0.125;
|
|
|
|
|
|
if (v > 4) v = 4;
|
2026-09-05 03:56:52 +08:00
|
|
|
|
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
|
|
|
|
|
|
if (typeof r.period_ms === "number") periodMs = r.period_ms;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
else periodMs = v;
|
2026-09-05 03:56:52 +08:00
|
|
|
|
if (typeof r.spin === "boolean") spinOn = r.spin;
|
|
|
|
|
|
try { localStorage.setItem(LS_PERIOD, String(periodMs)); } catch (_) {}
|
|
|
|
|
|
return periodMs;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* ── 电机 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) };
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/** 诊断用(控制台 motorDbg.transport)。 */
|
|
|
|
|
|
debug() {
|
|
|
|
|
|
return {
|
2026-09-05 03:56:52 +08:00
|
|
|
|
periodMs, slotPeriodMs, spinOn, pcoreMask, pcores: pcoreList(pcoreMask),
|
2026-09-04 12:18:06 +08:00
|
|
|
|
started, adapter: adapterSubstr,
|
|
|
|
|
|
configs: configs.size, versions: versions.size,
|
|
|
|
|
|
slots: seqSeen.size, snapshot: lastSnapshot,
|
|
|
|
|
|
slotsPerPdo: MOT_SLOTS_PER_PDO,
|
|
|
|
|
|
slotCount: MOTOR_SLOT_COUNT,
|
|
|
|
|
|
scanCycles: MOTOR_SCAN_CYCLES,
|
|
|
|
|
|
winBases: (lastSnapshot && lastSnapshot.win_bases) || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|