Merge branch 'feat/device-link'
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
---
|
||||
feature: device-link
|
||||
status: delivered
|
||||
updated: 2026-09-16
|
||||
branch: feat/device-link
|
||||
commits: 353d936..033ece2
|
||||
---
|
||||
|
||||
# Device Link / ConnectionHub(全链路统一连接层)
|
||||
|
||||
## Report
|
||||
|
||||
**What was built** — 在既有 DeviceLink 健康广播之上扩展为 ConnectionHub:`deviceLink` 统一持有
|
||||
WiFi IP、EtherCAT 网卡子串与 `ecatPost`,并保留 Owner/Client 快照。OTA(app.js)、CAN、
|
||||
can_monitor、motor、ecat_load、LAN9252 改为从 Hub 读端点/订在线布尔;BLE 仍经 `reportBle`
|
||||
上报。连接灯只覆盖布尔,不替换调用方语义文案。
|
||||
|
||||
**Verification** —
|
||||
- `node --check` 全部改动 JS → PASS
|
||||
- `test_motor_transport.mjs` 58/58 PASS
|
||||
- `test_motor_refresh.mjs` 62/62 PASS
|
||||
- `test_ecat_autostart.mjs` 19/19 PASS
|
||||
- 独立评审后修复 3 个 critical:读路径 setIp 风暴、CAN 文案覆盖、嵌入页误标轮询离线
|
||||
|
||||
**Journey log** —
|
||||
- 「共用 IP」≠「共用连接」——健康态必须有单一 Owner
|
||||
- Hub subscribe 只覆盖在线布尔,绝不替换调用方语义文案
|
||||
- 禁止在读路径调 setIp:Owner 会把每次 set-ip 当成立即重探
|
||||
- 嵌入 can.html 时 hub 不得写 fbPollStatus(那是 feedback 轮询自己的状态)
|
||||
## [S1] Problem
|
||||
|
||||
多 iframe 保活后,各页仍**各自**判定「设备连上了没有」,只是共用一个 IP 字符串:
|
||||
|
||||
- OTA 页轮询 `/system/status` → 顶栏「WiFi 已连接」
|
||||
- LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败」
|
||||
- EtherCAT 状态/负载又是一条独立轮询,网卡子串在 4 处重复读 `localStorage`
|
||||
- BLE 在页内 GATT,其它页完全不知
|
||||
- CAN / can_monitor / motor 各有一套 `getBaseUrl` 包装
|
||||
|
||||
用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话和重复端点配置。
|
||||
|
||||
## [S2] Design
|
||||
|
||||
### 原则
|
||||
|
||||
1. **外壳持有链路健康轮询**(tabs.html Owner),功能页不再各自判定「设备在线」。
|
||||
2. **BroadcastChannel**(`dcu-device-link`)广播快照;页内只订阅渲染。
|
||||
3. **端点配置只在 ConnectionHub**:IP(`dcu_esp_ip`)、网卡(`dcu_ecat_adapter`)读写集中。
|
||||
4. **命令仍页内发**,连接灯只认 DeviceLink 布尔(文案保留调用方语义)。
|
||||
5. BLE 为一等通道但单独适配:只广播状态,GATT 细节留在 `ble/*`。
|
||||
6. **外壳启动强制开总线**:Owner 首轮即 `status→open→motor/start`;成功后不再重复
|
||||
`motor/start`,掉线后 10s 退避重试。有线 EtherCAT 与 WiFi 不争网卡。
|
||||
|
||||
### ConnectionHub 客户端 API(`deviceLink`)
|
||||
|
||||
| API | 用途 |
|
||||
|-----|------|
|
||||
| `subscribe` / `getSnapshot` / `channelOnline` | 健康快照 |
|
||||
| `getIp` / `setIp` / `wifiBaseUrl` | WiFi 端点 |
|
||||
| `getEcatAdapter` / `setEcatAdapter` | EtherCAT 网卡子串 |
|
||||
| `ecatPost(path, body, timeoutMs)` | 统一 `/ecat/api/*` 代理请求 |
|
||||
| `reportBle` | BLE 状态上报 |
|
||||
|
||||
Owner 消息:`hello` / `snapshot` / `set-ip` / `ble-state`。
|
||||
|
||||
### 接入
|
||||
|
||||
| 位置 | 行为 |
|
||||
|------|------|
|
||||
| `tabs.html` | Owner |
|
||||
| Owner `bootstrapEcatBus` | 外壳启动即 open+motor/start,不必等电机页切 EtherCAT |
|
||||
| `connection.js` | 快照新鲜时覆盖 connected 布尔 |
|
||||
| `app.js` | IP/baseURL 走 `deviceLink`(保留 `#espIp` 输入回退) |
|
||||
| `can/dbc.js` + `can.js` | setDeviceIp 广播;顶栏灯订阅 hub |
|
||||
| `can_monitor.js` | baseURL 走 base-url/hub;灯订阅 hub |
|
||||
| `motor/main.js` | IP 订阅 hub 同步 `state.ip` |
|
||||
| `motor/transport-ecat.js` | 网卡子串经 hub 读写 |
|
||||
| `ota/ecat_load.js` | `deviceLink.ecatPost` |
|
||||
| `lab/lan9252_pm.html` | 灯订阅 hub;open 网卡经 hub |
|
||||
| `lab/spi_test.html` | 共享 IP 键(自包含页不深耦合请求层) |
|
||||
| `js/ble/gatt.js` | 通知成功后 reportBle(true),失败/断 false |
|
||||
|
||||
## [S3] Out of Scope
|
||||
|
||||
- 不串行化 SPI/CAN/OTA 命令队列
|
||||
- 不迁 SharedWorker
|
||||
- 不改固件协议
|
||||
- 不把 BLE GATT 强行对齐 HTTP/ECAT 请求接口
|
||||
- spi_test 保持可整文件删除,不注入业务请求逻辑
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] T1: device-link.js Owner+Client + BC 协议 (covers: S2)
|
||||
- [x] T2: tabs.html 启动 Owner (covers: S2)
|
||||
- [x] T3: OTA connection.js 接入 DeviceLink (covers: S2)
|
||||
- [x] T4: LAN9252 灯与 SPI 文案 (covers: S2)
|
||||
- [x] T5: BLE 状态上报 (covers: S2)
|
||||
- [x] T6: 语法检查 + 评审修复 (covers: S2)
|
||||
- [x] T7: Hub 扩展 wifiBaseUrl/ecatPost/adapter API (covers: S2)
|
||||
- [x] T8: OTA/CAN/motor/ecat_load/lab 迁到 Hub 端点 (covers: S2)
|
||||
+15
-1
@@ -157,7 +157,21 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809
|
||||
# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断,
|
||||
# 不留孤儿进程占着 Npcap 适配器和端口。
|
||||
# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。
|
||||
ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat"))
|
||||
def _resolve_ecat_dir(base_dir):
|
||||
"""定位 tools/ecat(ecat_native.exe 的仓库,与本仓库平级)。
|
||||
|
||||
从 git worktree(.../tools/ESP32/.worktrees/<slug>)启动时,BASE_DIR 的
|
||||
上一级是 .worktrees 而不是 tools,直接 join("..","ecat") 会拼成
|
||||
.../ESP32/.worktrees/ecat,找不到 exe。先走回主检出再取兄弟目录。
|
||||
"""
|
||||
d = os.path.abspath(base_dir)
|
||||
parent = os.path.dirname(d)
|
||||
if os.path.basename(parent) == ".worktrees":
|
||||
d = os.path.dirname(parent) # → .../tools/ESP32 主检出
|
||||
return os.path.normpath(os.path.join(d, "..", "ecat"))
|
||||
|
||||
|
||||
ECAT_DIR = _resolve_ecat_dir(BASE_DIR)
|
||||
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
|
||||
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
|
||||
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
updateStagesFromRelayState, updateSystemStatusUi
|
||||
} from './ota/stages.js';
|
||||
import { startEcatLoadPoller } from './ota/ecat_load.js';
|
||||
import { deviceLink } from './common/device-link.js';
|
||||
import {
|
||||
handleFileSelect, pickFileViaFSAPI, setupDropZone,
|
||||
parseIntelHex, isIntelHexText, convertHexFileToBin,
|
||||
@@ -50,14 +51,15 @@ function formatPercent(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return value.toFixed(1).replace(/\.0$/, '') + '%';
|
||||
}
|
||||
/** 设备 IP:优先本页 #espIp 输入框,否则 ConnectionHub。
|
||||
* 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。 */
|
||||
function getEspIp() {
|
||||
var el = document.getElementById('espIp');
|
||||
var ip = el && el.value ? el.value.trim() : '';
|
||||
if (!ip) {
|
||||
try { ip = (localStorage.getItem('dcu_esp_ip') || '').trim(); } catch (_) {}
|
||||
if (!ip) return deviceLink.getIp();
|
||||
if (ip !== deviceLink.getIp()) {
|
||||
try { deviceLink.setIp(ip); } catch (_) {}
|
||||
}
|
||||
if (!ip) ip = '192.168.31.127';
|
||||
try { localStorage.setItem('dcu_esp_ip', ip); } catch (_) {}
|
||||
return ip;
|
||||
}
|
||||
function getBaseUrl() {
|
||||
@@ -277,11 +279,11 @@ startEcatLoadPoller();
|
||||
var ipInput = document.getElementById('espIp');
|
||||
if (ipInput) {
|
||||
try {
|
||||
var savedIp = localStorage.getItem('dcu_esp_ip');
|
||||
var savedIp = deviceLink.getIp();
|
||||
if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim();
|
||||
} catch (_) {}
|
||||
ipInput.addEventListener('change', function () {
|
||||
try { localStorage.setItem('dcu_esp_ip', ipInput.value.trim() || '192.168.31.127'); } catch (_) {}
|
||||
try { deviceLink.setIp(ipInput.value.trim() || '192.168.31.127'); } catch (_) {}
|
||||
window.consecutivePollFailures = 0;
|
||||
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
|
||||
if (!window.statusPollInFlight) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
|
||||
import { setProgress, updateProgressFromAck } from './transfer.js';
|
||||
import { showBleVerifyInfo } from './verify.js';
|
||||
import { deviceLink } from '../common/device-link.js';
|
||||
import {
|
||||
clearOtaResultWaiter,
|
||||
resolveOtaResult,
|
||||
@@ -109,13 +110,16 @@ export async function connectBle() {
|
||||
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
|
||||
// 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3
|
||||
if (!state.notificationsEnabled) {
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E');
|
||||
return;
|
||||
}
|
||||
try { deviceLink.reportBle(true, device.name || ''); } catch (_) {}
|
||||
// Mark stages 1-2 as done
|
||||
setBleStage(3, '', null);
|
||||
setBleStageText('BLE 连接就绪,请选择固件文件');
|
||||
} catch (err) {
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
bleLog('BLE', `连接失败: ${err.message}`, 'E');
|
||||
if (err.name !== 'NotFoundError') {
|
||||
setBleStageError(err.message);
|
||||
@@ -129,6 +133,7 @@ export async function connectBle() {
|
||||
export function onDisconnected() {
|
||||
bleLog('BLE', '设备已断开', 'W');
|
||||
var wasExpected = state.expectedReboot;
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
resetUiAfterDisconnect();
|
||||
if (wasExpected && state.device) {
|
||||
// 是主动重启引起的断开,启动自动重连
|
||||
@@ -151,6 +156,7 @@ export async function disconnectBle() {
|
||||
if (state.device && state.device.gatt && state.device.gatt.connected) {
|
||||
state.device.gatt.disconnect();
|
||||
}
|
||||
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
||||
resetUiAfterDisconnect();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js';
|
||||
import { showToast } from './common/toast.js';
|
||||
import { initCanMonitor } from './can_monitor.js';
|
||||
import { deviceLink } from './common/device-link.js';
|
||||
|
||||
// ==================== 本地工具 ====================
|
||||
// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
|
||||
@@ -413,6 +414,12 @@ function init() {
|
||||
setTimeout(testConnection, 150);
|
||||
/* 周期性刷新顶栏 STA/IP/RSSI/运行时间(与监控 1Hz 解耦,避免重复 pill) */
|
||||
setInterval(testConnection, 5000);
|
||||
/* ConnectionHub 快照只覆盖在线布尔,不改 testConnection 写的语义文案(STA/HTTP…) */
|
||||
deviceLink.subscribe(function (s) {
|
||||
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
|
||||
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
|
||||
if (el.canConnPill) el.canConnPill.className = 'pill ' + (on ? 'online' : 'offline');
|
||||
});
|
||||
/* 右侧抽屉:CAN 接收监控 */
|
||||
initCanMonitor();
|
||||
}
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@
|
||||
import {
|
||||
DEFAULT_ESP_IP, getSavedDeviceIp, saveDeviceIp
|
||||
} from '../common/base-url.js';
|
||||
import { deviceLink } from '../common/device-link.js';
|
||||
|
||||
// ==================== 共享状态 ====================
|
||||
export var state = {
|
||||
@@ -46,11 +47,12 @@ export function getDeviceIp() {
|
||||
return DEFAULT_ESP_IP;
|
||||
}
|
||||
|
||||
/** @description 记住设备 IP(status 回报或其它页写入)。 */
|
||||
/** @description 记住设备 IP(status 回报或其它页写入),并广播给 ConnectionHub。 */
|
||||
export function setDeviceIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
var v = String(ip).trim();
|
||||
saveDeviceIp(v);
|
||||
try { deviceLink.setIp(v); } catch (_) {}
|
||||
if (el.toolIp) el.toolIp.value = v;
|
||||
}
|
||||
|
||||
|
||||
+18
-12
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import { showToast } from './common/toast.js';
|
||||
import { escapeHtml } from './common/utils.js';
|
||||
import { getBaseUrl as hubBaseUrl } from './common/base-url.js';
|
||||
import { deviceLink } from './common/device-link.js';
|
||||
|
||||
var state = { fbTimer: null, fbInFlight: false };
|
||||
|
||||
@@ -52,20 +54,15 @@ function cacheElements() {
|
||||
ids.forEach(function (id) { el[id] = document.getElementById(id); });
|
||||
}
|
||||
|
||||
/** WiFi 端点:优先本页输入框,否则 ConnectionHub 共享 IP(dcu_esp_ip)。 */
|
||||
function getBaseUrl() {
|
||||
var ip = '';
|
||||
if (el.monIp && el.monIp.value) {
|
||||
ip = el.monIp.value.trim();
|
||||
} else if (el.toolIp && el.toolIp.value) {
|
||||
ip = el.toolIp.value.trim();
|
||||
} else {
|
||||
try {
|
||||
var saved = localStorage.getItem('dcu_esp_ip');
|
||||
if (saved && saved.trim()) ip = saved.trim();
|
||||
} catch (_) {}
|
||||
if (el.monIp && el.monIp.value && el.monIp.value.trim()) {
|
||||
return 'http://' + el.monIp.value.trim();
|
||||
}
|
||||
if (!ip) ip = '192.168.31.127';
|
||||
return 'http://' + ip;
|
||||
if (el.toolIp && el.toolIp.value && el.toolIp.value.trim()) {
|
||||
return 'http://' + el.toolIp.value.trim();
|
||||
}
|
||||
return hubBaseUrl();
|
||||
}
|
||||
|
||||
function setConnState(ok, text) {
|
||||
@@ -304,6 +301,15 @@ export function initCanMonitor() {
|
||||
setConnState(false, '未连接');
|
||||
if (!state.bound) {
|
||||
state.bound = true;
|
||||
/* ConnectionHub:只覆盖独立页 monConn 灯的布尔;
|
||||
* 嵌入 can.html 时无 monConnPill,不得碰 fbPollStatus(那是 /can/feedback 自己的轮询态)。 */
|
||||
deviceLink.subscribe(function (s) {
|
||||
if (!el.monConnPill) return;
|
||||
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
|
||||
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
|
||||
el.monConnPill.className = 'pill ' + (on ? 'online' : 'offline');
|
||||
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (on ? 'ok' : 'err');
|
||||
});
|
||||
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
|
||||
if (el.monIp) el.monIp.addEventListener('change', testConnection);
|
||||
/* 嵌入 can.html 时 IP 与发送台共用 */
|
||||
|
||||
@@ -18,6 +18,7 @@ import { showToast } from './toast.js';
|
||||
import {
|
||||
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
|
||||
} from './heap-low-history.js';
|
||||
import { deviceLink } from './device-link.js';
|
||||
|
||||
// ==================== 渲染缓存(性能) ====================
|
||||
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
|
||||
@@ -55,6 +56,16 @@ function setWidthIfChanged(el, widthStr) {
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
export function updateConnectionUi(connected, text) {
|
||||
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
|
||||
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
|
||||
try {
|
||||
var snap = deviceLink.getSnapshot();
|
||||
if (snap && snap.ts && (Date.now() - snap.ts) < 5000) {
|
||||
/* 只覆盖在线布尔,保留调用方语义文案(设备忙/诊断中/STA…) */
|
||||
connected = !!snap.links.esp32.online;
|
||||
if (!text) text = connected ? '已连接' : '离线';
|
||||
}
|
||||
} catch (_) {}
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
|
||||
var pc = getEl('pillConn');
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* device-link.js — 全链路连接中心(ConnectionHub)
|
||||
*
|
||||
* 统一管理页面上的 WiFi / EtherCAT / BLE 三条链路:
|
||||
* - 健康轮询:外壳 Owner 常驻拉 ESP32 /system/status 与 EtherCAT status+load
|
||||
* - 端点配置:设备 IP(dcu_esp_ip)、网卡子串(dcu_ecat_adapter)只在此读写
|
||||
* - 通道适配:wifiBaseUrl / ecatPost 供各页复用,禁止再散落 localStorage
|
||||
*
|
||||
* 角色:
|
||||
* - Owner:tabs.html 加载前设 window.__dcuDeviceLinkOwner = true
|
||||
* - Client:任意页 import { deviceLink }
|
||||
* 独立直开页无 Owner 时,client 仍可读 IP / 发 ecatPost,只是无跨页快照。
|
||||
*
|
||||
* 命令仍由业务页自己发;连接灯只认本层健康态(布尔优先,文案保留调用方语义)。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
import {
|
||||
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp
|
||||
} from "./base-url.js";
|
||||
|
||||
var CH_NAME = "dcu-device-link";
|
||||
var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
|
||||
var ESP32_POLL_MS = 1000;
|
||||
var ECAT_POLL_MS = 2000;
|
||||
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
|
||||
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
||||
var LS_ECAT_SPIN = "dcu_ecat_spin";
|
||||
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
|
||||
var DEFAULT_ECAT_ADAPTER = "I210";
|
||||
|
||||
function nowMs() { return Date.now(); }
|
||||
|
||||
function readIp() {
|
||||
return getDeviceIp();
|
||||
}
|
||||
|
||||
function writeIp(ip) {
|
||||
saveDeviceIp(ip);
|
||||
}
|
||||
|
||||
function readAdapter() {
|
||||
try {
|
||||
var s = localStorage.getItem(LS_ECAT_ADAPTER);
|
||||
if (s && s.trim()) return s.trim();
|
||||
} catch (_) {}
|
||||
return DEFAULT_ECAT_ADAPTER;
|
||||
}
|
||||
|
||||
function writeAdapter(substr) {
|
||||
if (!substr || !String(substr).trim()) return;
|
||||
try { localStorage.setItem(LS_ECAT_ADAPTER, String(substr).trim()); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 /ecat/api/* 代理请求(同源,esp32_server 转发到 ecat_native)。
|
||||
* @param {string} path 相对路径,如 "status" / "app/load" / "open"
|
||||
* @param {object} body JSON body,缺省 {}
|
||||
* @param {number} timeoutMs 默认 5000
|
||||
* @returns {Promise<object>} 解析后的 JSON
|
||||
*/
|
||||
async function ecatPost(path, body, timeoutMs) {
|
||||
var p = String(path || "").replace(/^\/+/, "");
|
||||
var resp = await fetch("/ecat/api/" + p, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(timeoutMs || 5000)
|
||||
});
|
||||
var text = await resp.text();
|
||||
var json = {};
|
||||
try { json = text ? JSON.parse(text) : {}; } catch (_) {
|
||||
throw new Error("响应非合法 JSON");
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var err = new Error("HTTP " + resp.status);
|
||||
err.status = resp.status;
|
||||
err.json = json;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function emptySnapshot() {
|
||||
return {
|
||||
ts: 0,
|
||||
ip: readIp(),
|
||||
links: {
|
||||
esp32: { online: false, ageMs: -1, failStreak: 0 },
|
||||
ecat: { online: false, busOpen: false, op: false, loop: false,
|
||||
loadValid: false, loadPerm: 0, ageMs: -1 },
|
||||
ble: { online: false, deviceName: "", owner: null }
|
||||
},
|
||||
esp32Status: null
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Owner ───────────────────────── */
|
||||
|
||||
function createOwner() {
|
||||
var ch = null;
|
||||
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
||||
var snap = emptySnapshot();
|
||||
var esp32Fails = 0;
|
||||
var lastEsp32OkAt = 0;
|
||||
var esp32Timer = null;
|
||||
var ecatTimer = null;
|
||||
var stopped = false;
|
||||
/* 外壳启动即强制拉起总线(open + motor/start),不必等电机页切 EtherCAT。
|
||||
* 成功后不再重复 motor/start;掉线后按 ECAT_BUS_RETRY_MS 退避重试。 */
|
||||
var ecatBusReady = false;
|
||||
var ecatBusBusy = false;
|
||||
var ecatBusNextTryAt = 0;
|
||||
|
||||
function publish() {
|
||||
snap.ts = nowMs();
|
||||
snap.ip = readIp();
|
||||
if (ch) {
|
||||
try { ch.postMessage({ type: "snapshot", snap: snap }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function setEsp32(status, ok) {
|
||||
if (ok) {
|
||||
esp32Fails = 0;
|
||||
lastEsp32OkAt = nowMs();
|
||||
snap.links.esp32.online = true;
|
||||
snap.links.esp32.ageMs = 0;
|
||||
snap.esp32Status = status || null;
|
||||
} else {
|
||||
esp32Fails++;
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
|
||||
if (esp32Fails >= ESP32_FAIL_ONLINE) snap.links.esp32.online = false;
|
||||
}
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
publish();
|
||||
}
|
||||
|
||||
function pollEsp32() {
|
||||
if (stopped) return;
|
||||
var ac = null;
|
||||
try { ac = new AbortController(); } catch (_) {}
|
||||
var tmo = null;
|
||||
fetch(baseUrlFromIp() + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
|
||||
signal: ac ? ac.signal : undefined,
|
||||
cache: "no-store"
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}).then(function (j) {
|
||||
if (ac) { clearTimeout(tmo); }
|
||||
setEsp32(j, true);
|
||||
}).catch(function () {
|
||||
if (ac) { clearTimeout(tmo); }
|
||||
setEsp32(null, false);
|
||||
}).then(function () {
|
||||
esp32Timer = setTimeout(pollEsp32, ESP32_POLL_MS);
|
||||
});
|
||||
if (ac) {
|
||||
tmo = setTimeout(function () { try { ac.abort(); } catch (_) {} }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开总线:status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
|
||||
* 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
|
||||
* 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
|
||||
*/
|
||||
function bootstrapEcatBus() {
|
||||
if (stopped || ecatBusBusy) return;
|
||||
var now = nowMs();
|
||||
if (ecatBusReady) return;
|
||||
if (now < ecatBusNextTryAt) return;
|
||||
ecatBusBusy = true;
|
||||
ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS;
|
||||
|
||||
var adapter = readAdapter();
|
||||
var body = {};
|
||||
try {
|
||||
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
|
||||
if (v > 0) body.period_ms = v;
|
||||
var sp = localStorage.getItem(LS_ECAT_SPIN);
|
||||
if (sp === "0") body.spin = false;
|
||||
else if (sp === "1") body.spin = true;
|
||||
} catch (_) {}
|
||||
|
||||
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;
|
||||
}).catch(function () {
|
||||
/* 网线没插/从站未上电:退避后由 pollEcat 再触发 */
|
||||
}).then(function () {
|
||||
ecatBusBusy = false;
|
||||
});
|
||||
}
|
||||
|
||||
function pollEcat() {
|
||||
if (stopped) return;
|
||||
var stP = ecatPost("status", {}, 4000).catch(function () { return null; });
|
||||
var ldP = ecatPost("app/load", {}, 4000).catch(function () { return null; });
|
||||
|
||||
Promise.all([stP, ldP]).then(function (arr) {
|
||||
var st = arr[0], ld = arr[1];
|
||||
var e = snap.links.ecat;
|
||||
e.online = !!(st && st.ok && st.slave);
|
||||
e.busOpen = !!(st && st.ok);
|
||||
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
|
||||
e.op = !!(st && st.slave && st.slave.state != null && (st.slave.state & 0x0F) === 8);
|
||||
e.loadValid = !!(ld && ld.ok && ld.valid === true
|
||||
&& Number.isFinite(ld.age_ms) && ld.age_ms >= 0 && ld.age_ms <= 3000);
|
||||
e.loop = e.loadValid || e.op;
|
||||
e.loadPerm = (ld && Number.isFinite(ld.load_perm)) ? ld.load_perm : 0;
|
||||
e.ageMs = (ld && Number.isFinite(ld.age_ms)) ? ld.age_ms : -1;
|
||||
if (e.busOpen && e.loop) ecatBusReady = true;
|
||||
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
|
||||
publish();
|
||||
bootstrapEcatBus();
|
||||
}).catch(function () {
|
||||
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
|
||||
}).then(function () {
|
||||
if (!stopped) ecatTimer = setTimeout(pollEcat, ECAT_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function onMsg(ev) {
|
||||
var m = ev && ev.data;
|
||||
if (!m || !m.type) return;
|
||||
if (m.type === "hello") { publish(); return; }
|
||||
if (m.type === "set-ip") {
|
||||
writeIp(m.ip);
|
||||
try {
|
||||
fetch(baseUrlFromIp() + "/system/status", {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(2000)
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (j) { setEsp32(j, true); })
|
||||
.catch(function () { setEsp32(null, false); });
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (m.type === "ble-state") {
|
||||
snap.links.ble.online = !!m.online;
|
||||
snap.links.ble.deviceName = m.deviceName || "";
|
||||
snap.links.ble.owner = m.owner || (m.online ? "page" : null);
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch) ch.addEventListener("message", onMsg);
|
||||
pollEsp32();
|
||||
pollEcat();
|
||||
bootstrapEcatBus();
|
||||
publish();
|
||||
|
||||
return {
|
||||
stop: function () {
|
||||
stopped = true;
|
||||
if (esp32Timer) clearTimeout(esp32Timer);
|
||||
if (ecatTimer) clearTimeout(ecatTimer);
|
||||
if (ch) { try { ch.close(); } catch (_) {} }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Client ───────────────────────── */
|
||||
|
||||
function createClient() {
|
||||
var ch = null;
|
||||
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
||||
var listeners = [];
|
||||
var last = null;
|
||||
|
||||
function emit() {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
try { listeners[i](last); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (ch) {
|
||||
ch.addEventListener("message", function (ev) {
|
||||
var m = ev && ev.data;
|
||||
if (!m || m.type !== "snapshot" || !m.snap) return;
|
||||
last = m.snap;
|
||||
emit();
|
||||
});
|
||||
try { ch.postMessage({ type: "hello" }); } catch (_) {}
|
||||
}
|
||||
|
||||
function applyLocalBle(online, deviceName) {
|
||||
if (last) {
|
||||
last.links.ble.online = !!online;
|
||||
last.links.ble.deviceName = deviceName || "";
|
||||
last.links.ble.owner = online ? "iframe" : null;
|
||||
last.ts = nowMs();
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/* ── 快照 ── */
|
||||
getSnapshot: function () { return last; },
|
||||
subscribe: function (fn) {
|
||||
if (typeof fn !== "function") return function () {};
|
||||
listeners.push(fn);
|
||||
if (last) { try { fn(last); } catch (_) {} }
|
||||
return function () {
|
||||
var i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
/** 指定通道当前是否在线;无快照时回退 false(调用方自行降级) */
|
||||
channelOnline: function (name) {
|
||||
if (!last || !last.links) return false;
|
||||
var L = last.links[name];
|
||||
return !!(L && L.online);
|
||||
},
|
||||
|
||||
/* ── WiFi 端点 ── */
|
||||
setIp: function (ip) {
|
||||
writeIp(ip);
|
||||
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
|
||||
},
|
||||
getIp: function () { return readIp(); },
|
||||
wifiBaseUrl: function () { return baseUrlFromIp(); },
|
||||
|
||||
/* ── EtherCAT 端点与请求 ── */
|
||||
getEcatAdapter: function () { return readAdapter(); },
|
||||
setEcatAdapter: function (substr) {
|
||||
writeAdapter(substr);
|
||||
return readAdapter();
|
||||
},
|
||||
ecatPost: ecatPost,
|
||||
|
||||
/* ── BLE 状态上报 ── */
|
||||
reportBle: function (online, deviceName) {
|
||||
var payload = {
|
||||
type: "ble-state",
|
||||
online: !!online,
|
||||
deviceName: deviceName || "",
|
||||
owner: "iframe"
|
||||
};
|
||||
if (ch) { try { ch.postMessage(payload); } catch (_) {} }
|
||||
applyLocalBle(online, deviceName);
|
||||
},
|
||||
|
||||
hasOwner: function () { return !!last; }
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── bootstrap ───────────────────────── */
|
||||
|
||||
var deviceLink;
|
||||
try {
|
||||
if (window.__dcuDeviceLinkOwner) {
|
||||
window.__dcuDeviceLinkInstance = createOwner();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
deviceLink = createClient();
|
||||
export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter };
|
||||
export default deviceLink;
|
||||
+15
-4
@@ -35,13 +35,14 @@
|
||||
|
||||
/* IP 存取公共实现(localStorage dcu_esp_ip,与 OTA/CAN 页共用) */
|
||||
import {
|
||||
DEFAULT_ESP_IP, getDeviceIp, saveDeviceIp
|
||||
DEFAULT_ESP_IP, saveDeviceIp
|
||||
} from "../common/base-url.js";
|
||||
import { escapeHtml } from "../common/utils.js";
|
||||
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
|
||||
import {
|
||||
createTransport, loadTransportId, saveTransportId
|
||||
} from "./transport.js";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
|
||||
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
|
||||
import {
|
||||
@@ -167,9 +168,9 @@ const $ = (s) => document.querySelector(s);
|
||||
const nodeListEl = $("#nodeList");
|
||||
|
||||
/* ─── 工具函数 ─── */
|
||||
/** 读取主页写入的共享设备 IP(localStorage dcu_esp_ip) */
|
||||
/** 读取 ConnectionHub / base-url 共享设备 IP(dcu_esp_ip) */
|
||||
function getSharedDeviceIp() {
|
||||
return getDeviceIp();
|
||||
return deviceLink.getIp();
|
||||
}
|
||||
|
||||
/* ─── 输入持久化:刷新/重开页面后恢复上次输入 ───
|
||||
@@ -267,6 +268,7 @@ function setSharedDeviceIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
const v = String(ip).trim();
|
||||
state.ip = v;
|
||||
try { deviceLink.setIp(v); } catch (_) {}
|
||||
saveDeviceIp(v);
|
||||
const label = $("#ipLabel");
|
||||
if (label) label.textContent = "IP " + v;
|
||||
@@ -4011,8 +4013,17 @@ const scopeSys = (() => {
|
||||
/* ═══════════════════════ 初始化 ═══════════════════════ */
|
||||
/* 传输必须最先建:updateConnUi / renderStatusPanel 等都会读 state.tp。 */
|
||||
useTransport(loadTransportId());
|
||||
/* 本页不提供连接配置:WiFi 链路读主页 localStorage dcu_esp_ip,进入后自动探测 */
|
||||
/* 本页不提供连接配置:WiFi 链路读 ConnectionHub 共享 IP,进入后自动探测 */
|
||||
state.ip = getSharedDeviceIp();
|
||||
/* 其它页改 IP 后同步本页缓存(外壳 Owner 会再广播 set-ip 后的快照) */
|
||||
deviceLink.subscribe(function (snap) {
|
||||
if (!snap || !snap.ip) return;
|
||||
if (snap.ip !== state.ip) {
|
||||
state.ip = snap.ip;
|
||||
const label = $("#ipLabel");
|
||||
if (label) label.textContent = "IP " + state.ip;
|
||||
}
|
||||
});
|
||||
/* 先恢复用户上次输入(modeSelect / 目标值 / PID / 通道 / 选中节点),
|
||||
* 再 applyModeVisibility 才能让保存的 mode 决定字段可见性。 */
|
||||
loadUiState();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
/* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的
|
||||
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
|
||||
import { staleLimitMs } from "./refresh-rate.js";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数。
|
||||
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
||||
@@ -94,14 +95,8 @@ function fmtPeriod(ms) {
|
||||
export function createEcatTransport(ctx) {
|
||||
const { recordSlow, isTransient } = ctx;
|
||||
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I210,2026-09 网卡 I226→I210);
|
||||
* 存 localStorage 以便换网卡后不用每次重设。 */
|
||||
const LS_ADAPTER = "dcu_ecat_adapter";
|
||||
let adapterSubstr = "I210";
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_ADAPTER);
|
||||
if (saved && saved.trim()) adapterSubstr = saved.trim();
|
||||
} catch (_) {}
|
||||
/* 网卡匹配子串:统一从 ConnectionHub 读写(dcu_ecat_adapter),与 OTA/lab 页共用。 */
|
||||
let adapterSubstr = deviceLink.getEcatAdapter();
|
||||
|
||||
/* ── 传输内部累积状态 ──
|
||||
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
|
||||
@@ -530,7 +525,7 @@ export function createEcatTransport(ctx) {
|
||||
const v = String(substr || "").trim();
|
||||
if (!v || v === adapterSubstr) return false;
|
||||
adapterSubstr = v;
|
||||
try { localStorage.setItem(LS_ADAPTER, v); } catch (_) {}
|
||||
try { deviceLink.setEcatAdapter(v); } catch (_) {}
|
||||
started = false;
|
||||
lastSnapshot = null;
|
||||
try { await api("close", {}, 8000); } catch (_) { /* 没连接时无所谓 */ }
|
||||
|
||||
+6
-4
@@ -16,7 +16,10 @@
|
||||
* 自动停循环退回 INIT),负载值不再滚动,视为过期;
|
||||
* - 代理 503 —— ecat_native 没在跑。退避轮询(1s→5s→30s),避免不用 EtherCAT
|
||||
* 的用户按 1Hz 反复触发 esp32_server 的自动拉起逻辑。
|
||||
*
|
||||
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
|
||||
*/
|
||||
import { deviceLink } from '../common/device-link.js';
|
||||
|
||||
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
|
||||
var STALE_MS = 3000; /* 超过 3 个窗口没刷新即视为过期 */
|
||||
@@ -38,8 +41,8 @@ function setTitle(node, title) {
|
||||
}
|
||||
|
||||
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
|
||||
var TIP_OFF = TIP_BASE + ';当前不可用:需要 ECAT 总线已打开且过程数据循环在跑'
|
||||
+ '(如打开电机控制页选 EtherCAT 传输),且 DCU_APP 固件 >= 1.5.61。';
|
||||
var TIP_OFF = TIP_BASE + ';当前不可用:控制台外壳启动时会自动 open+motor/start;'
|
||||
+ '若仍为 --,检查网线/从站供电,或手动打开电机控制页选 EtherCAT。';
|
||||
|
||||
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
|
||||
function render(d) {
|
||||
@@ -73,8 +76,7 @@ async function tick() {
|
||||
if (!node) return; /* 页面上没有该指标就不再调度 */
|
||||
var ok = false;
|
||||
try {
|
||||
var resp = await fetch('/ecat/api/app/load', { method: 'POST', body: '{}' });
|
||||
if (resp.ok) ok = render(await resp.json());
|
||||
ok = render(await deviceLink.ecatPost('app/load', {}, 3000));
|
||||
} catch (_) { /* 代理/后端离线,走退避 */ }
|
||||
if (!ok) {
|
||||
failCount++;
|
||||
|
||||
+50
-16
@@ -186,6 +186,31 @@
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var logBox = $("log");
|
||||
|
||||
/* DeviceLink 统一健康态(外壳 Owner 经 BroadcastChannel 广播)。
|
||||
SPI 0xA2 失败 ≠ 设备离线:灯只认共享层;命令错误单独文案。 */
|
||||
window.__pmSetConn = null;
|
||||
|
||||
function setConn(ok, text) {
|
||||
var p = $("pillConn");
|
||||
p.className = "pill " + (ok ? "online" : "offline");
|
||||
$("pillConnTxt").textContent = text;
|
||||
}
|
||||
window.__pmSetConn = setConn;
|
||||
|
||||
/** SPI/业务失败:不得把「设备离线」打红(除非共享层已判离线)。 */
|
||||
function setSpiFail(msg) {
|
||||
log(msg, "err");
|
||||
var dl = window.__pmDeviceLink;
|
||||
var s = dl && dl.getSnapshot ? dl.getSnapshot() : null;
|
||||
if (s && s.links && s.links.esp32 && s.links.esp32.online) {
|
||||
setConn(true, "设备在线 · SPI 失败");
|
||||
} else if (!s) {
|
||||
/* 无外壳/尚未收到快照:退回旧语义 */
|
||||
setConn(false, "连接失败 · 5s 后重试");
|
||||
}
|
||||
/* 有快照且 offline:留给 DeviceLink subscribe 刷新灯 */
|
||||
}
|
||||
|
||||
/* 与 js/common/base-url.js 同 key 同默认值:全局唯一设备 IP,不设独立连接区 */
|
||||
var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127";
|
||||
function deviceIp() {
|
||||
@@ -302,16 +327,15 @@
|
||||
* 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。
|
||||
* pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */
|
||||
var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0;
|
||||
function setConn(ok, text) {
|
||||
var p = $("pillConn");
|
||||
p.className = "pill " + (ok ? "online" : "offline");
|
||||
$("pillConnTxt").textContent = text;
|
||||
}
|
||||
/** 主站看门狗豁免是否已置位(pmHold 幂等,防 2s 轮询刷屏) */
|
||||
var pmHoldOn = false;
|
||||
function bumpPollGen() { return ++pollGen; }
|
||||
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
|
||||
function markAwake() {
|
||||
clearSleepFlag();
|
||||
pmHold(0); /* 芯片已醒:解除主站看门狗豁免,恢复正常自愈 */
|
||||
/* 只在「之前置过豁免」时才解除:pollOnce 每 2s 调本函数,
|
||||
* 无脑 pmHold(0) 会把「主站看门狗豁免解除」刷屏。 */
|
||||
if (pmHoldOn) pmHold(0);
|
||||
$("sleepBanner").textContent =
|
||||
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复";
|
||||
}
|
||||
@@ -336,7 +360,7 @@
|
||||
if (gen !== pollGen || pollPaused) return;
|
||||
pollFail++;
|
||||
if (pollFail >= 3) {
|
||||
setConn(false, "连接失败 · 5s 后重试");
|
||||
setSpiFail("SPI 轮询失败 ×3(IP " + deviceIp() + ")· 5s 后重试");
|
||||
pollTimer = setTimeout(probe, 5000);
|
||||
} else {
|
||||
pollTimer = setTimeout(pollOnce, 2000);
|
||||
@@ -359,8 +383,7 @@
|
||||
log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok");
|
||||
})
|
||||
.catch(function (e) {
|
||||
setConn(false, "连接失败 · 5s 后重试");
|
||||
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
|
||||
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
|
||||
pollTimer = setTimeout(probeEspOnly, 5000);
|
||||
});
|
||||
}
|
||||
@@ -374,8 +397,7 @@
|
||||
pollOnce();
|
||||
})
|
||||
.catch(function (e) {
|
||||
setConn(false, "连接失败 · 5s 后重试");
|
||||
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
|
||||
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
|
||||
pollTimer = setTimeout(probe, 5000);
|
||||
});
|
||||
}
|
||||
@@ -413,11 +435,14 @@
|
||||
* 即被偷偷拉回 OP——2026-09-12 实测休眠 ~20s 自动"复活")。解除时主站
|
||||
* 自带 60s 冷却,让本页唤醒流程先走完。best-effort:native 未起只记日志。 */
|
||||
function pmHold(on) {
|
||||
return ecatPost("pm/hold", { on: on }, 5000).then(function (r) {
|
||||
log("主站看门狗豁免" + (on ? "置位" : "解除") +
|
||||
var want = !!on;
|
||||
if (pmHoldOn === want) return Promise.resolve();
|
||||
return ecatPost("pm/hold", { on: want }, 5000).then(function (r) {
|
||||
if (r.ok) pmHoldOn = want;
|
||||
log("主站看门狗豁免" + (want ? "置位" : "解除") +
|
||||
(r.ok ? "" : "失败: " + r.log.join(";")), r.ok ? "ok" : "warn");
|
||||
}).catch(function (e) {
|
||||
log("主站看门狗豁免" + (on ? "置位" : "解除") + "失败: " + e.message, "warn");
|
||||
log("主站看门狗豁免" + (want ? "置位" : "解除") + "失败: " + e.message, "warn");
|
||||
});
|
||||
}
|
||||
function ecatEnsureOpen() {
|
||||
@@ -425,8 +450,8 @@
|
||||
return ecatPost("native/health", {}, 6000).then(function (h) {
|
||||
var open = h.json && h.json.bus_open;
|
||||
if (open) return { ok: true, log: ["总线已 open"], json: h.json };
|
||||
var adapter = "I210";
|
||||
try { adapter = localStorage.getItem("dcu_ecat_adapter") || "I210"; } catch (e) {}
|
||||
var adapter = (window.__pmDeviceLink && window.__pmDeviceLink.getEcatAdapter)
|
||||
? window.__pmDeviceLink.getEcatAdapter() : "I210";
|
||||
log("总线未 open,调用 /api/open (" + adapter + ") …");
|
||||
return ecatPost("open", { substr: adapter }, 20000);
|
||||
});
|
||||
@@ -604,5 +629,14 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module">
|
||||
import { deviceLink } from '../js/common/device-link.js?v=20260916a';
|
||||
window.__pmDeviceLink = deviceLink;
|
||||
deviceLink.subscribe(function (s) {
|
||||
if (!s || !s.links || !window.__pmSetConn) return;
|
||||
var e = s.links.esp32;
|
||||
window.__pmSetConn(!!e.online, e.online ? '已连接' : '离线');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -631,7 +631,7 @@ const spiOut = $("#out"), spiHist = $("#hist"), btnAbort = $("#btnAbort");
|
||||
const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")];
|
||||
let spiCtl = null;
|
||||
try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {}
|
||||
/* 设备 IP 与控制台其它页共用 dcu_esp_ip:其它页改过 IP,这里不必再手敲一遍 */
|
||||
/* 设备 IP 与 ConnectionHub / 其它页共用 dcu_esp_ip:其它页改过 IP,这里不必再手敲一遍 */
|
||||
if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip").value = shared; }
|
||||
|
||||
function spiBusy(busy, cls, text) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 从 git worktree 启动 esp32_server 时,ECAT_DIR 必须指回主检出的
|
||||
* tools/ecat,而不是 .../ESP32/.worktrees/ecat。
|
||||
* 复刻 _resolve_ecat_dir 的纯路径逻辑(无 DOM/无网络)。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** 与 esp32_server._resolve_ecat_dir 同构(JS 侧只测路径形状)。 */
|
||||
function resolveEcatDir(baseDir) {
|
||||
let d = path.resolve(baseDir);
|
||||
const parent = path.dirname(d);
|
||||
if (path.basename(parent) === ".worktrees") {
|
||||
d = path.dirname(parent);
|
||||
}
|
||||
return path.normalize(path.join(d, "..", "ecat"));
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓", name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(" ✗", name, "—", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[worktree ECAT_DIR]");
|
||||
|
||||
check("主检出 BASE_DIR → 兄弟 tools/ecat", () => {
|
||||
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
|
||||
});
|
||||
|
||||
check("worktree BASE_DIR → 仍指主检出的 tools/ecat", () => {
|
||||
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32/.worktrees/device-link");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
|
||||
});
|
||||
|
||||
check("嵌套 slug 也走 .worktrees 父级", () => {
|
||||
const base = path.resolve("G:/x/tools/ESP32/.worktrees/foo-bar");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/x/tools/ecat"), got);
|
||||
});
|
||||
|
||||
/* BASE_DIR 恒为 esp32_server.py 所在目录;普通主检出已在第一项覆盖。 */
|
||||
|
||||
if (failed) {
|
||||
console.log(`失败 ${failed} 项`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("通过 3 项,失败 0 项");
|
||||
@@ -179,5 +179,8 @@
|
||||
<!-- app 窗口租约: 本外壳窗口活着期间保持与本地服务的连接,
|
||||
全部窗口关闭后服务端自动停 EtherCAT PDO 循环(见 js/common/app-lease.js) -->
|
||||
<script src="js/common/app-lease.js"></script>
|
||||
<!-- DeviceLink Owner:全链路健康轮询只在本外壳跑一次,各 iframe 订阅 -->
|
||||
<script>window.__dcuDeviceLinkOwner = true;</script>
|
||||
<script type="module" src="js/common/device-link.js?v=20260916c"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user