feat(link): ConnectionHub 统一 WiFi/ECAT/BLE 端点与健康态

各页不再各自读写 dcu_esp_ip / dcu_ecat_adapter:deviceLink 提供
wifiBaseUrl/ecatPost/adapter API,OTA/CAN/motor/ecat_load/lab 走同一入口。
连接灯只覆盖在线布尔,保留调用方语义文案。
This commit is contained in:
cbb
2026-09-16 12:12:24 +08:00
parent 353d936abb
commit 033ece2cca
11 changed files with 210 additions and 110 deletions
+45 -28
View File
@@ -3,56 +3,63 @@ feature: device-link
status: delivered
updated: 2026-09-16
branch: feat/device-link
commits: be26f4daa27cba70b4baeaeb3865f38ed51b2e71..6b5a9f9
commits: 353d936..HEAD
---
# Device Link(全链路统一连接层)
# Device Link / ConnectionHub(全链路统一连接层)
## Report
**What was built** — `js/common/device-link.js` 在 `tabs.html` 以 Owner 身份常驻轮询
ESP32 `/system/status` 与 EtherCAT `/ecat/api/status`+`app/load`,经
`BroadcastChannel('dcu-device-link')` 广播快照;功能页 Client 订阅。
`connection.js` 的连接灯在快照 <5s 时只覆盖**在线布尔**,保留「设备忙」等语义文案。
LAN9252 页 SPI 失败显示「设备在线 · SPI 失败」,不再误标设备离线。
BLE GATT 在通知开启成功后 `reportBle(true)`,失败/断开上报 false。
**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`:device-link.js / connection.js / gatt.js PASS
- tabs.html、lan9252_pm 内联 script 抽出 `node --check` PASS
- 独立评审后修复:ECAT `Promise.all` 补 `.catch`+必重挂;去掉跨 iframe 无效的
`window.__dcuDeviceLinkSnap`;BLE 半开连接不再 sticky online
- `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
- 外壳 Owner + 页内 Client 双 BC 实例符合 BroadcastChannel 语义(同上下文不自发自收)
- SPI/业务失败与设备离线必须分文案,否则子系统故障会伪装成掉线
- ECAT 轮询的 reschedule 必须在 `.catch` 之后,否则一次异常永久停摆
- Hub subscribe 只覆盖在线布尔,绝不替换调用方语义文案
- 禁止在读路径调 setIp:Owner 会把每次 set-ip 当成立即重探
- 嵌入 can.html 时 hub 不得写 fbPollStatus(那是 feedback 轮询自己的状态)
## [S1] Problem
多 iframe 保活后,各页仍**各自** `fetch` 设备,只是共用 `localStorage.dcu_esp_ip`:
多 iframe 保活后,各页仍**各自**判定「设备连上了没有」,只是共用一个 IP 字符串:
- OTA 页轮询 `/system/status` → 顶栏「WiFi 已连接」
- LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败 · signal timed out」
- EtherCAT 负载/状态又是一条独立轮询
- LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败」
- EtherCAT 状态/负载又是一条独立轮询,网卡子串在 4 处重复读 `localStorage`
- BLE 在页内 GATT,其它页完全不知
- CAN / can_monitor / motor 各有一套 `getBaseUrl` 包装
用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话。
用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话和重复端点配置。
## [S2] Design
### 原则
1. **外壳持有链路健康轮询**(tabs.html 常驻),功能页不再各自判定「设备在线」。
1. **外壳持有链路健康轮询**(tabs.html Owner),功能页不再各自判定「设备在线」。
2. **BroadcastChannel**(`dcu-device-link`)广播快照;页内只订阅渲染。
3. **命令仍页内发**,但**连接灯只认 DeviceLink**(布尔优先,文案保留调用方语义)。
4. SPI 失败 →「设备在线 · SPI 失败」;DeviceLink offline 才显示离线。
5. 独立直开页:无快照时回退本地轮询。
3. **端点配置只在 ConnectionHub**:IP(`dcu_esp_ip`)、网卡(`dcu_ecat_adapter`)读写集中。
4. **命令仍页内发**,连接灯只认 DeviceLink 布尔(文案保留调用方语义)。
5. BLE 为一等通道但单独适配:只广播状态,GATT 细节留在 `ble/*`。
### 模块
### ConnectionHub 客户端 API(`deviceLink`)
`js/common/device-link.js` Owner+Client;消息:`hello` / `snapshot` / `set-ip` / `ble-state`。
| 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`。
### 接入
@@ -60,7 +67,14 @@ BLE GATT 在通知开启成功后 `reportBle(true)`,失败/断开上报 false
|------|------|
| `tabs.html` | Owner |
| `connection.js` | 快照新鲜时覆盖 connected 布尔 |
| `lan9252_pm.html` | 订阅灯 + `setSpiFail` |
| `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
@@ -68,7 +82,8 @@ BLE GATT 在通知开启成功后 `reportBle(true)`,失败/断开上报 false
- 不串行化 SPI/CAN/OTA 命令队列
- 不迁 SharedWorker
- 不改固件协议
- BLE GATT 仍留在页内(只广播状态)
- 不把 BLE GATT 强行对齐 HTTP/ECAT 请求接口
- spi_test 保持可整文件删除,不注入业务请求逻辑
## Tasks
@@ -78,3 +93,5 @@ BLE GATT 在通知开启成功后 `reportBle(true)`,失败/断开上报 false
- [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)
+8 -6
View File
@@ -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) {
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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 与发送台共用 */
+103 -45
View File
@@ -1,39 +1,82 @@
/**
* device-link.js — 全链路设备健康层(外壳 Owner + 功能页 Client)
* device-link.js — 全链路连接中心(ConnectionHub)
*
* 问题:各页各自 fetch,只共用 IP 字符串 → 一页「已连接」一页「失败」。
* 方案:tabs.html 常驻 Owner 轮询 ESP32 /system/status 与 EtherCAT /api/status
* + /api/app/load,经 BroadcastChannel('dcu-device-link') 广播快照;
* 功能页只订阅渲染。业务命令(SPI/CAN/OTA)仍页内发,失败不得把
* 「设备离线」灯打红——灯只认本层。
* 统一管理页面上的 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
* - Owner:tabs.html 加载前设 window.__dcuDeviceLinkOwner = true
* - Client:任意页 import { deviceLink }
* 独立直开页面(无外壳)时 client 收不到 snapshot,可退回本地轮询。
* 独立直开页无 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 LS_ESP_IP = "dcu_esp_ip";
var DEFAULT_IP = "192.168.31.127";
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
var DEFAULT_ECAT_ADAPTER = "I210";
function nowMs() { return Date.now(); }
function readIp() {
try {
var s = localStorage.getItem(LS_ESP_IP);
if (s && s.trim()) return s.trim();
} catch (_) {}
return DEFAULT_IP;
return getDeviceIp();
}
function writeIp(ip) {
if (!ip || !String(ip).trim()) return;
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
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() {
@@ -89,11 +132,10 @@ function createOwner() {
function pollEsp32() {
if (stopped) return;
var ip = readIp();
var ac = null;
try { ac = new AbortController(); } catch (_) {}
var tmo = null;
fetch("http://" + ip + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
fetch(baseUrlFromIp() + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
signal: ac ? ac.signal : undefined,
cache: "no-store"
}).then(function (r) {
@@ -115,15 +157,8 @@ function createOwner() {
function pollEcat() {
if (stopped) return;
var base = "/ecat/api/";
var stP = fetch(base + "status", {
method: "POST", body: "{}", cache: "no-store",
signal: AbortSignal.timeout(4000)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
var ldP = fetch(base + "app/load", {
method: "POST", body: "{}", cache: "no-store",
signal: AbortSignal.timeout(4000)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
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];
@@ -151,11 +186,13 @@ function createOwner() {
if (m.type === "hello") { publish(); return; }
if (m.type === "set-ip") {
writeIp(m.ip);
/* 立刻重拉 ESP32(旧 timer 下一拍也会跟上) */
try { fetch("http://" + readIp() + "/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); });
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;
}
@@ -207,10 +244,19 @@ function createClient() {
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 {
/** 当前快照(可能为 null,Owner 尚未广播时) */
/* ── 快照 ── */
getSnapshot: function () { return last; },
/** 订阅快照;返回取消函数 */
subscribe: function (fn) {
if (typeof fn !== "function") return function () {};
listeners.push(fn);
@@ -220,11 +266,30 @@ function createClient() {
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",
@@ -233,16 +298,9 @@ function createClient() {
owner: "iframe"
};
if (ch) { try { ch.postMessage(payload); } catch (_) {} }
/* 本地立刻改一份,单页直开也能看到 */
if (last) {
last.links.ble.online = !!online;
last.links.ble.deviceName = deviceName || "";
last.links.ble.owner = online ? "iframe" : null;
last.ts = nowMs();
emit();
}
applyLocalBle(online, deviceName);
},
/** 是否有外壳 Owner 在管(收到过 snapshot 或自身是 owner) */
hasOwner: function () { return !!last; }
};
}
@@ -257,5 +315,5 @@ try {
} catch (_) {}
deviceLink = createClient();
export { deviceLink };
export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter };
export default deviceLink;
+15 -4
View File
@@ -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();
+4 -9
View File
@@ -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 (_) { /* 没连接时无所谓 */ }
+4 -2
View File
@@ -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 个窗口没刷新即视为过期 */
@@ -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++;
+2 -2
View File
@@ -443,8 +443,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);
});
+1 -1
View File
@@ -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) {