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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user