feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点: 1. esp32_server.py - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在) - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP 2. js/common/desktop-bridge.js(新) - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥 3. connection.js + responsive.css + components.css - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画 - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘 4. device-link.js - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时 - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期 5. motor/main.js + transport-ecat.js - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出 - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp 6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态 7. .gitignore - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
+7
-1
@@ -2,7 +2,7 @@
|
||||
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
|
||||
*/
|
||||
import { showToast } from './common/toast.js';
|
||||
import { escapeHtml } from './common/utils.js';
|
||||
import { escapeHtml, flashBusyNow } from './common/utils.js';
|
||||
import { getBaseUrl as hubBaseUrl } from './common/base-url.js';
|
||||
import { deviceLink } from './common/device-link.js';
|
||||
|
||||
@@ -66,6 +66,9 @@ function getBaseUrl() {
|
||||
}
|
||||
|
||||
function setConnState(ok, text) {
|
||||
/* 无设备静默态:离线时掐掉常驻动画,规则见 css/responsive.css 的
|
||||
html[data-dcu-calm="1"] 段(160Hz 屏上每帧重算+重绘 ≈ 白烧一核)。 */
|
||||
document.documentElement.dataset.dcuCalm = ok ? '0' : '1';
|
||||
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
|
||||
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
|
||||
if (el.monConnTxt) el.monConnTxt.textContent = text;
|
||||
@@ -82,6 +85,9 @@ function setConnState(ok, text) {
|
||||
async function pollFeedback() {
|
||||
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
|
||||
if (state.fbInFlight) return;
|
||||
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,0xC1 必被链路层拒
|
||||
* (ESP32 侧一帧一条拒绝日志),问了也是白问。标记随刷写收尾自撤。 */
|
||||
if (flashBusyNow()) return;
|
||||
state.fbInFlight = true;
|
||||
var ac = new AbortController();
|
||||
var timer = setTimeout(function () { ac.abort(); }, 3000);
|
||||
|
||||
@@ -55,6 +55,27 @@ function setWidthIfChanged(el, widthStr) {
|
||||
}
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
/**
|
||||
* 无设备静默态:设备离线且没有传输在跑时,给 <html> 挂 data-dcu-calm="1",
|
||||
* 由 responsive.css 统一掐掉常驻动画。160Hz 屏上这些动效每帧触发重算+重绘,
|
||||
* 实测未接设备时 renderer+GPU 各占约一核的七成。
|
||||
*/
|
||||
export function setQuietMode(quiet) {
|
||||
var v = quiet ? '1' : '0';
|
||||
if (document.documentElement.dataset.dcuCalm !== v) {
|
||||
document.documentElement.dataset.dcuCalm = v;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本轮是否有传输/OTA 在跑(此时即使设备短暂不响应也要保留动效)。 */
|
||||
function transferOngoing() {
|
||||
try {
|
||||
return !!(window.pollingPausedForUpload || window.currentOtaTarget ||
|
||||
window.isEsp32Rebooting || window.bootEcatBusy ||
|
||||
(typeof window.otaSessionActive === 'function' && window.otaSessionActive()));
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
export function updateConnectionUi(connected, text) {
|
||||
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
|
||||
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
|
||||
@@ -87,6 +108,7 @@ export function updateConnectionUi(connected, text) {
|
||||
setTextIfChanged(bc, connected ? '在线' : '离线');
|
||||
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
|
||||
}
|
||||
setQuietMode(!connected && !transferOngoing());
|
||||
}
|
||||
export function updateHealthUi(healthCode, freeHeap) {
|
||||
var healthNames = ['正常', '警告', '危险', '关机'];
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* desktop-bridge.js — 桌面壳(pywebview)原生桥探测与调用
|
||||
*
|
||||
* 形态约定:
|
||||
* esp32_app.vbs → --window → WebView2:JS 可经 window.pywebview.api 调到
|
||||
* GUI 进程 Python,由它直连 ecat_native(不经浏览器 fetch、不经页面服务子进程代理)。
|
||||
* esp32_web.vbs / 浏览器标签:无 pywebview,本模块返回 null,调用方回落 HTTP。
|
||||
*
|
||||
* 电机页在 tabs.html 的 iframe 内:pywebview 只注入顶层窗口,同源 iframe
|
||||
* 必须向上找 window.parent / window.top 上的 pywebview.api。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
function collectApi() {
|
||||
const roots = [];
|
||||
try { roots.push(window); } catch (_) {}
|
||||
try {
|
||||
if (window.parent && window.parent !== window) roots.push(window.parent);
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.top && window.top !== window) roots.push(window.top);
|
||||
} catch (_) {}
|
||||
for (const w of roots) {
|
||||
try {
|
||||
const api = w.pywebview && w.pywebview.api;
|
||||
if (api && typeof api.ecat_api === "function") return api;
|
||||
} catch (_) { /* 跨源 parent 会抛 */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let _api = null;
|
||||
|
||||
/** @description 取桌面桥 API;没有则 null(不缓存失败,pywebview 可能稍后注入)。 */
|
||||
export function getDesktopEcatApi() {
|
||||
if (_api && typeof _api.ecat_api === "function") return _api;
|
||||
_api = collectApi();
|
||||
return _api;
|
||||
}
|
||||
|
||||
/** @description 是否跑在 pywebview 桌面壳里(有壳但桥方法未就绪时也可能 true)。 */
|
||||
export function isDesktopShell() {
|
||||
try {
|
||||
return !!(window.pywebview ||
|
||||
(window.parent && window.parent !== window && window.parent.pywebview) ||
|
||||
(window.top && window.top !== window && window.top.pywebview));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面桥 POST。path 接受 "motor/send" / "/api/motor/send" / "/ecat/api/motor/send"。
|
||||
* @returns {Promise<object>} 与 native 同构的 JSON
|
||||
*/
|
||||
export async function desktopEcatPost(path, body, timeoutMs) {
|
||||
const api = getDesktopEcatApi();
|
||||
if (!api) throw new Error("desktop bridge unavailable");
|
||||
return api.ecat_api(path, body == null ? {} : body, timeoutMs || 4000);
|
||||
}
|
||||
|
||||
/** @description 状态栏用:当前 ECAT 控制通道形态。 */
|
||||
export function desktopBridgeLabel() {
|
||||
return getDesktopEcatApi() ? "APP 原生桥" : "HTTP 代理";
|
||||
}
|
||||
+50
-12
@@ -24,6 +24,7 @@ var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
|
||||
var ESP32_POLL_MS = 1000;
|
||||
var ECAT_POLL_MS = 2000;
|
||||
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
|
||||
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
|
||||
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
||||
var LS_ECAT_SPIN = "dcu_ecat_spin";
|
||||
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
|
||||
@@ -164,9 +165,13 @@ function createOwner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 开总线:status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
|
||||
* 开总线:health →(未就绪则)status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
|
||||
* 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
|
||||
* 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
|
||||
*
|
||||
* 已健康(bus_open + loop)时**直接复用**,不打 motor/start:
|
||||
* 那条路由会先 loop_stop 再重进 OP,和电机页探测撞车时会假超时
|
||||
* (横幅 signal timed out)并平白增加 SSC 楔死风险。
|
||||
*/
|
||||
function bootstrapEcatBus() {
|
||||
if (stopped || ecatBusBusy) return;
|
||||
@@ -180,26 +185,44 @@ function createOwner() {
|
||||
var body = {};
|
||||
try {
|
||||
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
|
||||
if (v > 0) body.period_ms = v;
|
||||
/* 无显式值 / 值过慢 → 固定 0.25ms。1ms+DC 会让发送出口回到 ~1s。 */
|
||||
if (v > 0 && v <= 0.5) body.period_ms = v;
|
||||
else body.period_ms = 0.25;
|
||||
var sp = localStorage.getItem(LS_ECAT_SPIN);
|
||||
if (sp === "0") body.spin = false;
|
||||
else if (sp === "1") body.spin = true;
|
||||
} catch (_) {}
|
||||
} catch (_) { body.period_ms = 0.25; }
|
||||
/* EtherCAT 控制必须 DC SYNC0≈PDO 周期,且周期应保持 250µs 档 */
|
||||
{
|
||||
var per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25;
|
||||
body.period_ms = per;
|
||||
var us = Math.round(per * 1000);
|
||||
if (us < 250) us = 250;
|
||||
body.dc_cycle_us = us;
|
||||
body.dc_shift_us = 0;
|
||||
}
|
||||
|
||||
ecatPost("status", {}, 6000).then(function (st) {
|
||||
var open = !!(st && st.ok);
|
||||
return open ? Promise.resolve(st)
|
||||
: ecatPost("open", { substr: adapter }, 20000);
|
||||
}).then(function (r) {
|
||||
if (r && r.ok === false) {
|
||||
throw new Error((r.log && r.log.join(" / ")) || "open 失败");
|
||||
ecatPost("native/health", {}, 3000).then(function (h) {
|
||||
if (h && h.ok && h.bus_open && h.loop) {
|
||||
ecatBusReady = true;
|
||||
return null; /* 已在跑:跳过 open + motor/start */
|
||||
}
|
||||
return ecatPost("motor/start", body, 30000);
|
||||
return ecatPost("status", {}, 6000).then(function (st) {
|
||||
var open = !!(st && st.ok);
|
||||
return open ? Promise.resolve(st)
|
||||
: ecatPost("open", { substr: adapter }, 20000);
|
||||
}).then(function (r) {
|
||||
if (r && r.ok === false) {
|
||||
throw new Error((r.log && r.log.join(" / ")) || "open 失败");
|
||||
}
|
||||
return ecatPost("motor/start", body, 30000);
|
||||
});
|
||||
}).then(function (r) {
|
||||
if (r && r.ok === false) {
|
||||
throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败");
|
||||
}
|
||||
ecatBusReady = true;
|
||||
if (r) ecatBusReady = true;
|
||||
}).catch(function () {
|
||||
/* 网线没插/从站未上电:退避后由 pollEcat 再触发 */
|
||||
}).then(function () {
|
||||
@@ -227,6 +250,21 @@ function createOwner() {
|
||||
if (e.busOpen && e.loop) ecatBusReady = true;
|
||||
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
|
||||
publish();
|
||||
/* 自愈:DC/周期被配错时发送出口秒级 —— 2s 轮询里拉回 250µs */
|
||||
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
|
||||
ecatDcGuardBusy = true;
|
||||
ecatPost("native/health", {}, 2000).then(function (h) {
|
||||
if (!h || !h.ok) return null;
|
||||
var dcs = Number(h.dc_cycle_us);
|
||||
var per = Number(h.period_ms);
|
||||
if (h.dc === 1 && dcs === 250 && (h.period_ms == null || Math.abs(per - 0.25) < 0.001)) return null;
|
||||
return ecatPost("motor/start", {
|
||||
period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0
|
||||
}, 30000);
|
||||
}).catch(function () {}).then(function () {
|
||||
ecatDcGuardBusy = false;
|
||||
});
|
||||
}
|
||||
bootstrapEcatBus();
|
||||
}).catch(function () {
|
||||
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
|
||||
|
||||
@@ -58,3 +58,20 @@ export function bytesToHex(bytes) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 当前是否有刷写/OTA 在跑(跨 iframe 共享)。
|
||||
* 多 iframe 保活外壳下各功能页是**独立文档**,window 变量不通用(刷写页的
|
||||
* window.bootEcatBusy 在 CAN 页里读不到),所以走 localStorage 这一条共享通道:
|
||||
* 刷写方(js/ota/boot_ecat.js)写 dcu_flash_busy_until = 预计结束时刻(ms),
|
||||
* 轮询方据此避让。带到期时间,页面中途关掉/异常也不会把标记永久挂住。
|
||||
* @returns {boolean} 刷写窗口内为 true
|
||||
*/
|
||||
export function flashBusyNow() {
|
||||
try {
|
||||
var until = parseInt(localStorage.getItem('dcu_flash_busy_until'), 10);
|
||||
return Number.isFinite(until) && until > Date.now();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+708
-152
File diff suppressed because it is too large
Load Diff
+338
-54
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
|
||||
*
|
||||
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库),经本地页面服务器
|
||||
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
|
||||
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
|
||||
* 后端是 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 后台循环维持。
|
||||
*
|
||||
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
|
||||
* 1. 服务没起 → 代理侧 _ecat_autostart() 拉起 tools/ecat\build_native\ecat_native.exe(已有实例只复用)
|
||||
@@ -11,6 +14,9 @@
|
||||
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
|
||||
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
|
||||
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
|
||||
* 探测自身是**幂等**的:native/health 显示 bus_open+loop 时直接复用,
|
||||
* 不再打 motor/start(那条路由会先 loop_stop,热路径假超时 / 楔死风险)。
|
||||
* probe 超时后另有 healthCheck 只读复核,避免「实际已连上却报未连」。
|
||||
*
|
||||
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
|
||||
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
|
||||
@@ -36,6 +42,9 @@
|
||||
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
|
||||
import { staleLimitMs } from "./refresh-rate.js";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
import {
|
||||
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
||||
} from "../common/desktop-bridge.js?v=20260923g";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数。
|
||||
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
||||
@@ -101,8 +110,11 @@ export function createEcatTransport(ctx) {
|
||||
/* ── 传输内部累积状态 ──
|
||||
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
|
||||
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
|
||||
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
|
||||
const versions = new Map(); // node -> {node, seq, version, at} 供 fetchVersionNode
|
||||
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
|
||||
/* xrx 变更指纹:段3 只有「最近一帧」,内容不变时不要重复 dispatch,
|
||||
* 否则每拍都把旧版本 seq+1,查询判定会失真。 */
|
||||
let lastXrxKey = "";
|
||||
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
|
||||
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
|
||||
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
|
||||
@@ -132,10 +144,35 @@ export function createEcatTransport(ctx) {
|
||||
let started = false; // 后台循环是否已启动
|
||||
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
|
||||
|
||||
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
|
||||
async function api(path, body, timeoutMs) {
|
||||
const url = "/ecat/api/" + path.replace(/^\/+/, "");
|
||||
/**
|
||||
* 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) {
|
||||
const t0 = performance.now();
|
||||
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;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
@@ -155,28 +192,64 @@ export function createEcatTransport(ctx) {
|
||||
const text = await resp.text();
|
||||
let json = {};
|
||||
try { json = text ? JSON.parse(text) : {}; } catch (e) {
|
||||
throw new Error("EtherCAT 服务响应非合法 JSON");
|
||||
const err = new Error("EtherCAT 服务响应非合法 JSON");
|
||||
err.transient = true; /* 代理半截响应:下一拍重试即可,勿累计断线 */
|
||||
throw err;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
/* 代理连不上后端会给 503 + 同构信封;后端的 404 体里带
|
||||
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
|
||||
const err = new Error(
|
||||
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
|
||||
err.status = resp.status;
|
||||
err.transient = (resp.status === 503);
|
||||
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
|
||||
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);
|
||||
/* "后台 PDO 循环未启动" 是可自愈的:下一拍 ensureStarted 会重开,
|
||||
* 归为瞬时错误,避免刚切过来的一瞬间就把页面判成断线。 */
|
||||
err.transient = /未启动|周期异常|排队|busy/i.test(msg);
|
||||
/* 可自愈业务态:循环未起/未连接/忙/排队 —— ensureStarted/probe 会重开。
|
||||
* 把这些算硬失败会在高压毛刺下 5 拍内把页面判成断线(实测踩过)。 */
|
||||
err.transient = /未启动|未连接|周期异常|排队|busy|暂时|稍后|重试|超时|timed?\s*out|bridge|序列化/i.test(msg);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
@@ -211,8 +284,58 @@ export function createEcatTransport(ctx) {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
|
||||
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
|
||||
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
|
||||
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
|
||||
async function isBusHealthy() {
|
||||
try {
|
||||
const h = await readHealth();
|
||||
return !!(h && h.busOpen && h.loop);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 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;
|
||||
}
|
||||
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
||||
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
||||
if (!reqPeriodMs) {
|
||||
if (await isBusHealthy()) {
|
||||
started = true;
|
||||
try {
|
||||
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
|
||||
syncFromSnapshot(snap);
|
||||
return snap;
|
||||
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
|
||||
return { ok: true, period_ms: periodMs, spin: spinOn };
|
||||
}
|
||||
}
|
||||
const body = {};
|
||||
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
||||
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
|
||||
@@ -227,8 +350,21 @@ export function createEcatTransport(ctx) {
|
||||
localStorage.setItem(LS_PERIOD_MIG, "1");
|
||||
}
|
||||
const v = parseFloat(localStorage.getItem(LS_PERIOD));
|
||||
if (v > 0) body.period_ms = v;
|
||||
} catch (_) { /* 隐私模式忽略 */ }
|
||||
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
|
||||
if (v > 0 && v !== 1) 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。 */
|
||||
{
|
||||
let per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
|
||||
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;
|
||||
}
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
|
||||
@@ -247,8 +383,14 @@ export function createEcatTransport(ctx) {
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
|
||||
function dispatchFrame(id, dlc, dataHex) {
|
||||
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) {
|
||||
if (!dataHex) return;
|
||||
const node = (id >> 16) & 0xF;
|
||||
const sub = id & 0xFFFF;
|
||||
@@ -256,10 +398,22 @@ export function createEcatTransport(ctx) {
|
||||
configs.set(node, dataHex); // CONFIG 目标值回报
|
||||
} else if (sub === 0x00FF) {
|
||||
const prev = versions.get(node);
|
||||
/* 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;
|
||||
}
|
||||
versions.set(node, {
|
||||
node,
|
||||
seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
|
||||
seq: (prev && prev.seq != null) ? prev.seq + 1 : 1,
|
||||
version: dataHex,
|
||||
at: Date.now(),
|
||||
});
|
||||
} else if (sub === 0x0400 || sub === 0x0401) {
|
||||
nodeIdReports.set(node, dataHex);
|
||||
@@ -269,64 +423,101 @@ export function createEcatTransport(ctx) {
|
||||
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
|
||||
async function drainRx() {
|
||||
const r = await api("motor/rx", {}, 3000);
|
||||
if (!r || !r.frames) return;
|
||||
if (!r || !r.frames) return 0;
|
||||
let n = 0;
|
||||
for (const f of r.frames) {
|
||||
if (!f.valid) continue;
|
||||
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
|
||||
if ((f.id & 0xFFFF) === 0x0100) continue;
|
||||
dispatchFrame(f.id, f.dlc, f.data);
|
||||
dispatchFrame(f.id, f.dlc, f.data, false);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照 → WiFi 的 /motor/state 契约。
|
||||
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
|
||||
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
|
||||
*
|
||||
* ★ 快照槽位会「闪没」:dense 窗口 win_count 偶发 3→2(实测 wkc 仍 3/3),
|
||||
* 电机整只从 motors[] 消失。若直接映射,UI 立刻「节点离线」+波形虚线。
|
||||
* 这里对**刚见过**的槽做 last-known 保持:缺席但仍落在 staleLimit 窗口内
|
||||
* → 沿用上一拍 payload 且 online=true;超过窗口才真离线。
|
||||
*/
|
||||
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;
|
||||
|
||||
function snapshotToMotorState(snap) {
|
||||
const now = Date.now();
|
||||
const motors = [];
|
||||
const present = new Set();
|
||||
|
||||
for (const m of (snap.motors || [])) {
|
||||
const key = m.ch + ":" + m.node;
|
||||
present.add(key);
|
||||
const prev = seqSeen.get(key);
|
||||
let rx = prev ? prev.rx : 0;
|
||||
let at = prev ? prev.at : now;
|
||||
let iv = prev ? prev.iv : 0;
|
||||
if (!prev || prev.seq !== m.seq) {
|
||||
if (prev) {
|
||||
/* 实测变化间隔 EWMA(α=0.3):判活阈值跟着电机回报率走。电机改回报
|
||||
* 档后 ~3 次新间隔收敛;突发降速无法预知,收敛前最多闪一次离线。 */
|
||||
const d = now - prev.at;
|
||||
iv = iv ? iv + (d - iv) * 0.3 : d;
|
||||
}
|
||||
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
|
||||
rx = rx + 1;
|
||||
at = now;
|
||||
}
|
||||
seqSeen.set(key, { seq: m.seq, at, rx, iv });
|
||||
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。
|
||||
* 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */
|
||||
const fresh = (now - at) <= staleLimitMs(iv);
|
||||
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,
|
||||
});
|
||||
motors.push({
|
||||
node: m.node,
|
||||
ch: m.ch,
|
||||
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
|
||||
online: !!m.online && fresh,
|
||||
online,
|
||||
dlc: m.dlc,
|
||||
rx_count: rx,
|
||||
seq: m.seq,
|
||||
data: m.data,
|
||||
/* CONFIG 从批量接收区累积而来(WiFi 侧是每电机自带的) */
|
||||
config_data: configs.get(m.node) || "",
|
||||
config_seq: 0,
|
||||
config_rx_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/* 本拍缺席的槽:仍在 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,
|
||||
});
|
||||
}
|
||||
|
||||
const head = snap.head || {};
|
||||
return {
|
||||
have_data: motors.length > 0,
|
||||
active_count: head.active || 0,
|
||||
/* 快照 ts 是服务端 time.time()(秒);与本地时钟比会受偏差影响,
|
||||
* 只用它算个量级即可,UI 也只把 age 当"有没有数据"的粗指标。 */
|
||||
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
|
||||
boot_active: !!head.boot_active,
|
||||
motors,
|
||||
@@ -342,24 +533,66 @@ export function createEcatTransport(ctx) {
|
||||
supportsPeriodSync: false,
|
||||
|
||||
sendHint(ch) {
|
||||
return "POST /api/motor/send \u00b7 CH" + ch +
|
||||
(periodMs ? " \u00b7 PDO " + fmtPeriod(periodMs) : "");
|
||||
return desktopBridgeLabel() + " · motor/send · CH" + ch +
|
||||
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接探测 —— 一路自动到能用:
|
||||
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
|
||||
* 网卡没开 → ensureOpen() 代替用户点"连接"
|
||||
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
|
||||
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
|
||||
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
|
||||
* 循环已在跑 → 直接复用(健康位只读,**不**再打 motor/start)
|
||||
* 没进 OP / 循环没跑 → ensureStarted() 走 motor/start
|
||||
* 用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
|
||||
*
|
||||
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
|
||||
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
|
||||
* 只有 /api/status 通不算「从站在线」—— 循环没跑、从站没进 OP 时界面全是
|
||||
* "--"。probe/healthCheck 只保证主站链路可用(刷写也要能进页面);
|
||||
* 工具栏在线灯由 main.js 的 isTargetOnline 按 AL/op/STATUS 判定。
|
||||
*
|
||||
* **为什么健康时必须跳过 motor/start**:它在 _BUS_EXCLUSIVE 语义下会先
|
||||
* loop_stop 再核实 OP/重进;外壳 Owner 可能已把总线跑起来,页面每次探测
|
||||
* 都去停一次循环,既假超时(横幅「signal timed out」)又增加 SSC 楔死风险。
|
||||
*/
|
||||
async probe() {
|
||||
const st = await ensureOpen();
|
||||
await ensureStarted();
|
||||
/* 把 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 循环未运行");
|
||||
/* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
|
||||
try {
|
||||
const raw = await api("native/health", {}, 2000);
|
||||
const dc = raw && raw.dc;
|
||||
const dcs = raw && raw.dc_cycle_us;
|
||||
const per = raw && raw.period_ms;
|
||||
if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) {
|
||||
await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000);
|
||||
}
|
||||
} 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;
|
||||
try {
|
||||
const snap = await api("motor/state", {}, 2500);
|
||||
if (snap && snap.ok === true) syncFromSnapshot(snap);
|
||||
} catch (_) { /* 快照暂不可读不影响健康判定 */ }
|
||||
return st;
|
||||
},
|
||||
|
||||
@@ -407,14 +640,34 @@ export function createEcatTransport(ctx) {
|
||||
if (snap.period_ms > 0) periodMs = snap.period_ms;
|
||||
if (typeof snap.spin === "boolean") spinOn = snap.spin;
|
||||
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
|
||||
/* 有帧待取才去消费,省一次往返 */
|
||||
/* 消费批量接收区。
|
||||
* queued = 队列深度(可取帧数);count/pending 是本拍 PDO 新增/溢出。
|
||||
* 三者任一有值才 drain。旧后端无 queued 时,查询等待期靠 drainPendingRx 兜底。 */
|
||||
const rxb = snap.rx_batch;
|
||||
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
|
||||
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;
|
||||
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
|
||||
}
|
||||
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
|
||||
if (snap.xrx && snap.xrx.data) {
|
||||
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
|
||||
/* 段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 = "";
|
||||
}
|
||||
return snapshotToMotorState(snap);
|
||||
},
|
||||
@@ -493,6 +746,42 @@ export function createEcatTransport(ctx) {
|
||||
return versions.get(node) || null;
|
||||
},
|
||||
|
||||
/** 版本查询等待期强制消费 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;
|
||||
}
|
||||
},
|
||||
|
||||
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
|
||||
takeNodeIdReport(node) {
|
||||
const hex = nodeIdReports.get(node);
|
||||
@@ -554,22 +843,17 @@ export function createEcatTransport(ctx) {
|
||||
},
|
||||
|
||||
/**
|
||||
* 热换 PDO 周期(毫秒,允许亚毫秒:0.25 = 4kHz)。真机总线一送一回约
|
||||
* 165µs,比这更快会被循环自然自限,报多少都不会弄坏东西。
|
||||
*
|
||||
* 走 /api/motor/period 而不是 /api/motor/start,理由与 setSpin 相同:
|
||||
* motor/start 在总线互斥表里会先停循环,停手 >1s 从站掉 OP、重进可能楔死;
|
||||
* 周期只是循环每拍重读的变量,热改全程喂狗不断流,随时可调可反复调。
|
||||
* 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。
|
||||
*
|
||||
* 取值存 localStorage:后端重启后 ensureStarted 会原样带回。
|
||||
* 恢复默认:删掉 dcu_ecat_period_ms,跟随后端默认(固件修好前别低于 1ms)。
|
||||
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
|
||||
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
|
||||
*/
|
||||
async setPdoPeriod(ms) {
|
||||
const v = Number(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;
|
||||
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;
|
||||
if (typeof r.spin === "boolean") spinOn = r.spin;
|
||||
try { localStorage.setItem(LS_PERIOD, String(periodMs)); } catch (_) {}
|
||||
return periodMs;
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
|
||||
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
|
||||
* sendBurst(frames, {suffix,dlc}) -> results[]
|
||||
* fetchVersionNode(node) -> {node,seq,version}|null
|
||||
* fetchVersionNode(node) -> {node,seq,version,at?}|null
|
||||
* 可选:drainPendingRx() -> Promise 版本等待期强制消费批量接收区(ECAT)
|
||||
* setPeriod(ms) -> void 不支持则空实现
|
||||
* teardown() -> void 切走时收尾
|
||||
* 可选:takeNodeIdReport(node)、debug()
|
||||
@@ -30,7 +31,7 @@
|
||||
"use strict";
|
||||
|
||||
import { createWifiTransport } from "./transport-wifi.js";
|
||||
import { createEcatTransport } from "./transport-ecat.js";
|
||||
import { createEcatTransport } from "./transport-ecat.js?v=20260611c";
|
||||
|
||||
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
||||
export const LS_TRANSPORT = "dcu_motor_transport";
|
||||
|
||||
+103
-3
@@ -11,6 +11,9 @@
|
||||
* 0x04/0x02 改基址、段间空洞填 0xFF、逐行校验和),并用 checkGd32ImageFile 拦掉
|
||||
* 「引导程序包 / 向量表非法」。所以这里只读扁平字节,不再抄一份 HEX 解析器 ——
|
||||
* 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。
|
||||
* ⚠ 但刷前必须按 FSAPI 句柄重读磁盘(readFreshImage):本地重编会覆盖同路径的 .hex,
|
||||
* 页面里的 window.gd32File 还是"选文件那一刻"的旧内容,不复读就会把旧包灌进 A 区,
|
||||
* 表现为「控制台全绿、板子版本/Build 不变」。与 upload-esp32/BLE/电机 OTA 同一条规矩。
|
||||
*
|
||||
* 为什么点「直灌」时才 open+push,而不是选完文件就推:
|
||||
* native 的镜像缓冲跨请求存活(op=abort 才丢)。选文件时推 = 服务端可能留着上一轮
|
||||
@@ -160,8 +163,18 @@ function setRoleBadge(role, sw) {
|
||||
if (swEl) swEl.textContent = sw || (r === 'unknown' ? '—' : '版本未知');
|
||||
}
|
||||
|
||||
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */
|
||||
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。
|
||||
* 优先 FSAPI 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能
|
||||
* "热重载"按句柄重读磁盘;传统 <input> 选完即无句柄,重编后会退化成中止+提示。 */
|
||||
export function bootEcatPick() {
|
||||
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
|
||||
Promise.resolve(window.pickFileViaFSAPI('gd32')).catch(function (e) {
|
||||
if (e && e.name !== 'AbortError') {
|
||||
window.addLog('W', 'ECAT刷写', 'FSAPI 选择失败: ' + (e.message || e) + ',可改用上传区重新选择');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
var input = el('fileInputGd32');
|
||||
if (!input) {
|
||||
window.showToast('找不到 GD32 文件选择控件', 'error');
|
||||
@@ -232,7 +245,10 @@ function setEcatPct(p, msg) {
|
||||
var fill = el('progressFill');
|
||||
var pct = el('progressPct');
|
||||
var txt = el('progressTxt');
|
||||
if (fill) fill.style.width = n + '%';
|
||||
if (fill) {
|
||||
fill.style.width = n + '%';
|
||||
fill.classList.toggle('live', n > 0 && n < 100);
|
||||
}
|
||||
if (pct) pct.textContent = n + '%';
|
||||
if (txt && msg != null && msg !== '') txt.textContent = msg;
|
||||
}
|
||||
@@ -379,6 +395,63 @@ function clearArm() {
|
||||
if (b) { b.textContent = 'EtherCAT 刷写 APP'; b.className = 'btn btn-block'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 刷前从磁盘重读固件(与 WiFi/BLE/电机 OTA 同一条规矩)。
|
||||
* 本地重编会覆盖同路径的 .hex,而 window.gd32File 只是"选文件那一刻"的 File 对象,
|
||||
* 之后磁盘变了它不会跟着变 —— 直接拿它刷,灌进去的是上次那包,表现为
|
||||
* 「控制台全绿、板子版本/Build 不变」(2026-09-18 现场)。
|
||||
* @return {Promise<Uint8Array|null>} null = 拿不到可信镜像,调用方必须中止
|
||||
* (此刻总线未动、A 区未擦,中止零代价)。
|
||||
*/
|
||||
async function readFreshImage() {
|
||||
var handle = window.gd32FileHandle;
|
||||
if (!handle) {
|
||||
/* 拖拽/传统文件框选的:没有句柄就无从确认磁盘上现在是什么,而本路径
|
||||
* begin 会先整区擦 A 区 —— 比 WiFi 那条(警告后继续)严格一档,直接收手。 */
|
||||
window.addLog('E', 'ECAT刷写',
|
||||
'无法确认镜像是否为最新:当前文件未经文件系统句柄选择(拖拽/传统文件框)。' +
|
||||
'请点上面「GD32 固件」那张卡的上传区重新选择后再刷 —— ' +
|
||||
'本路径会先擦 A 区,拿旧包灌写等于白刷');
|
||||
window.showToast('请重新选择 GD32 固件后再刷', 'error');
|
||||
return null;
|
||||
}
|
||||
var fresh;
|
||||
try {
|
||||
fresh = await handle.getFile();
|
||||
} catch (e) {
|
||||
window.addLog('E', 'ECAT刷写',
|
||||
'重新读取固件失败:' + (e && e.message ? e.message : e) +
|
||||
'(磁盘上那个产物被移动/删除?)—— 已收手,一个字节都没写');
|
||||
window.showToast('固件重新读取失败,请重新选择', 'error');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
/* 统一走 processFile:.hex 按最新源文件重转 BIN,.bin 也过同一道身份校验
|
||||
* (checkGd32ImageFile 挡「引导程序包 / 向量表非法」),结果写回 window.gd32File。
|
||||
* 与 upload-esp32 的热重载分支同款。 */
|
||||
if (typeof window.processFile !== 'function') {
|
||||
throw new Error('processFile 不可用');
|
||||
}
|
||||
await window.processFile(fresh, 'gd32', { silent: true, skipHandlePersist: true });
|
||||
} catch (e) {
|
||||
window.addLog('E', 'ECAT刷写', '重读的镜像解析失败:' + (e && e.message ? e.message : e));
|
||||
window.showToast('固件解析失败,请重新选择', 'error');
|
||||
return null;
|
||||
}
|
||||
var file = window.gd32File;
|
||||
if (!file) {
|
||||
window.addLog('E', 'ECAT刷写',
|
||||
'重读的镜像未通过 GD32 身份校验(引导程序包/向量表非法)—— 已收手,一个字节都没写');
|
||||
return null;
|
||||
}
|
||||
var mtime = file.lastModified
|
||||
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
|
||||
: '未知时间';
|
||||
window.addLog('I', 'ECAT刷写',
|
||||
'镜像热重载: ' + file.name + '(' + bytes(file.size) + ',磁盘 mtime ' + mtime + ')');
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 七步一次点完:读镜像 → 开总线并把镜像收进主站 → 交接进 BOOT
|
||||
* → 回读身份 → 邮箱灌写(run) → 从站复位 → 回读确认。
|
||||
@@ -407,6 +480,11 @@ export async function bootEcatFlash() {
|
||||
clearArm();
|
||||
|
||||
window.bootEcatBusy = true;
|
||||
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
|
||||
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
|
||||
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
|
||||
* 带到期时间(10min,覆盖最坏灌写 + probe 自愈),页面异常也不会把标记挂死。 */
|
||||
try { localStorage.setItem('dcu_flash_busy_until', String(Date.now() + 10 * 60 * 1000)); } catch (_) {}
|
||||
refreshBootEcatUi();
|
||||
var runBtn = el('bootEcatRunBtn');
|
||||
if (runBtn) { runBtn.disabled = true; runBtn.textContent = '刷写中…'; }
|
||||
@@ -417,7 +495,15 @@ export async function bootEcatFlash() {
|
||||
setEcatStage(1, 'live', '读取中');
|
||||
setEcatStatus('active', '读取镜像…');
|
||||
try {
|
||||
var u8 = new Uint8Array(await file.arrayBuffer());
|
||||
/* 步骤 1:按句柄从磁盘重读镜像 —— 不能信页面缓存(见 readFreshImage 注) */
|
||||
var u8 = await readFreshImage();
|
||||
if (!u8 || u8.length === 0) {
|
||||
setEcatStage(1, 'err', '未取得镜像');
|
||||
setEcatStatus('err', '镜像没就绪');
|
||||
setEcatPct(0, '没拿到可信镜像 —— 一个字节都没写(见日志)');
|
||||
return;
|
||||
}
|
||||
file = window.gd32File; /* readFreshImage 已把重读结果写回共享槽 */
|
||||
setEcatStage(1, 'done', bytes(u8.length));
|
||||
setEcatStage(2, 'live', '开总线');
|
||||
setEcatStatus('active', '打开主站总线…');
|
||||
@@ -429,6 +515,10 @@ export async function bootEcatFlash() {
|
||||
return;
|
||||
}
|
||||
setEcatStage(2, 'live', 'open');
|
||||
/* 刷前身份读数(读数,不是门):记下 0x1008/0x100A,末尾与刷后比对 ——
|
||||
* 「刷完版本没变」要能被控制台自证,而不是只看流程全绿。
|
||||
* 此拍在交接之前,BOOT 那 10s 窗口还没开始计时。 */
|
||||
var pre = await bootEcatProbe(false);
|
||||
setEcatStatus('active', '推送到主站…');
|
||||
setEcatPct(8, '打开主站缓冲…');
|
||||
var o = await api({ op: 'open', size: u8.length });
|
||||
@@ -579,6 +669,14 @@ export async function bootEcatFlash() {
|
||||
setEcatStage(7, 'done', '完成');
|
||||
setEcatStatus('ok', '刷写完成');
|
||||
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
|
||||
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
|
||||
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
|
||||
if (pre && pre.sw && after.sw && after.sw === pre.sw) {
|
||||
window.addLog('W', 'ECAT刷写',
|
||||
'0x100A 刷前刷后都是 ' + after.sw + ' —— 若这次是换版本/改代码,' +
|
||||
'确认刷的是最新镜像(本页刷前已按句柄重读磁盘;仍相同则应查构建产物是否真被重编)');
|
||||
window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning');
|
||||
}
|
||||
} else if (okRun) {
|
||||
/* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复,
|
||||
* 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档,
|
||||
@@ -612,6 +710,8 @@ export async function bootEcatFlash() {
|
||||
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
|
||||
} finally {
|
||||
window.bootEcatBusy = false;
|
||||
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
|
||||
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
|
||||
if (runBtn) {
|
||||
runBtn.textContent = 'EtherCAT 刷写 APP';
|
||||
runBtn.className = 'btn btn-block';
|
||||
|
||||
+5
-1
@@ -241,7 +241,11 @@ export function setProgress(pct, text) {
|
||||
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
|
||||
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
|
||||
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
|
||||
if (progressFillEl) progressFillEl.style.width = n + '%';
|
||||
if (progressFillEl) {
|
||||
progressFillEl.style.width = n + '%';
|
||||
/* 斜纹/扫光按刷新率重绘,只在 0<n<100 的真传输窗口才挂 */
|
||||
progressFillEl.classList.toggle('live', n > 0 && n < 100);
|
||||
}
|
||||
if (progressTxtEl) progressTxtEl.textContent = text;
|
||||
if (progressPctEl) progressPctEl.textContent = n + '%';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user