From 670b833dc6ca5b9932b2d222dedabe0f35f25ee3 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 02:50:12 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(link):=20DeviceLink=20=E5=A4=96?= =?UTF-8?q?=E5=A3=B3=E5=8D=95=E4=BE=8B=E7=BB=9F=E4=B8=80=20ESP32/ECAT/BLE?= =?UTF-8?q?=20=E5=81=A5=E5=BA=B7=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各页只共用 IP、各自 fetch 导致同屏「已连接/连接失败」分裂。 外壳 Owner 轮询 /system/status 与 EtherCAT 状态,BroadcastChannel 广播快照;连接灯只认共享层,SPI 失败单独文案不覆盖设备离线。 --- docs/compose/spec/device-link.md | 105 ++++++++++++ js/ble/gatt.js | 4 + js/common/connection.js | 10 ++ js/common/device-link.js | 263 +++++++++++++++++++++++++++++++ lab/lan9252_pm.html | 47 ++++-- tabs.html | 3 + 6 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 docs/compose/spec/device-link.md create mode 100644 js/common/device-link.js diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md new file mode 100644 index 0000000..627cf32 --- /dev/null +++ b/docs/compose/spec/device-link.md @@ -0,0 +1,105 @@ +--- +feature: device-link +status: designed +updated: 2026-09-16 +branch: feat/device-link +commits: +--- + +# Device Link(全链路统一连接层) + +## Report + +## [S1] Problem + +多 iframe 保活后,各页仍**各自** `fetch` 设备,只是共用 `localStorage.dcu_sp_ip`: + +- OTA 页轮询 `/system/status` → 顶栏「WiFi 已连接」 +- LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败 · signal timed out」 +- EtherCAT 负载/状态又是一条独立轮询 +- BLE 在页内 GATT,其它页完全不知 + +用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话。 +结果:一页绿灯一页红灯,像系统坏了,其实是**健康态没有单一真相源**。 + +## [S2] Design + +### 原则 + +1. **外壳持有链路健康轮询**(tabs.html 常驻),功能页不再各自判定「设备在线」。 +2. **BroadcastChannel**(`dcu-device-link`)广播快照;页内只订阅渲染。 +3. **命令仍页内发**(SPI 0xA2、CAN send、OTA upload 等),但**连接灯只认 DeviceLink**。 +4. SPI/业务命令失败显示为 **子系统故障**(如「SPI 超时」),不得覆盖成「设备离线」——除非 DeviceLink 已判 ESP32 离线。 +5. 页面可**独立打开**(无外壳):检测不到 owner 时回退本地轮询,行为与改前一致。 + +### 模块 + +`js/common/device-link.js` 同一文件双角色: + +| 角色 | 谁加载 | 职责 | +|------|--------|------| +| Owner | `tabs.html`(`window.__dcuDeviceLinkOwner = true`) | 轮询 ESP32 `/system/status`、ECAT `/ecat/api/status`+`/api/app/load`;维护快照并广播 | +| Client | 各功能页 | `subscribe()` 收快照;`setIp()`;`reportBle()` 上报 GATT 状态 | + +### 快照契约 + +```js +{ + ts: 1710000000000, + ip: "192.168.31.127", + links: { + esp32: { online: boolean, ageMs: number, failStreak: number }, + ecat: { online: boolean, busOpen: boolean, op: boolean, loop: boolean, + loadValid: boolean, loadPerm: number, ageMs: number }, + ble: { online: boolean, deviceName: string, owner: string|null } + }, + esp32Status: object|null // 最近一次 /system/status 全文(可 null) +} +``` + +### 消息 + +| type | 方向 | 语义 | +|------|------|------| +| `hello` | Client→Owner | 请求立刻回一份 snapshot | +| `snapshot` | Owner→All | 全量快照(ESP32 状态变化或 1s 心跳时发) | +| `set-ip` | Client→Owner | 改设备 IP;Owner 写 localStorage 并立刻重拉 | +| `ble-state` | Client→Owner | `{online, deviceName}` GATT 连/断 | + +Owner 用 `BroadcastChannel`;无 BC 的环境 client 降级为「仅 localStorage 读 IP + 本地轮询」。 + +### 接入点(本轮) + +| 页面/模块 | 改动 | +|-----------|------| +| `tabs.html` | 置 owner 标志并 `import` device-link 启动 | +| `pages/index.html` + `js/common/polling.js`/`connection.js` | 有 DeviceLink 时:连接胶囊/健康读快照;`pollStatus` 仍可保留作数据源或改为消费 `esp32Status`(见任务) | +| `lab/lan9252_pm.html` | 连接灯改读 DeviceLink;`/spi/send` 失败文案改为「SPI 失败」 | +| `js/ble/gatt.js` 或 session | connect/disconnect 时 `reportBle` | +| `js/ota/ecat_load.js` | 可选:owner 已有 load 时页内 poller 可只读快照(本轮先并存,避免双写 UI) | + +### 超时与判定 + +- ESP32:成功 → `online=true, failStreak=0`;连续失败 ≥3 → `online=false` +- ECAT:`/api/status` ok 且 slave 存在 → 轨道 `busOpen`;`/app/load` `valid && age_ms≤3000` → load 新鲜 +- BLE:仅 GATT 主人上报;无上报时 `online=false` + +### 与 keep-alive 的关系 + +DeviceLink 跑在**外壳**,与 iframe 是否可见无关;隐藏页仍能显示统一灯(打开即亮)。 + +## [S3] Out of Scope + +- 不把 SPI/CAN/OTA 命令全部塞进共享队列(串行化另开) +- 不迁 SharedWorker +- 不改 ESP32/ECAT 固件协议 +- 不合并 BLE GATT 到外壳(本轮只广播状态) + +## Tasks + +- [ ] T1: `device-link.js` Owner+Client + BroadcastChannel 协议 — acceptance: 单测式手工:外壳起后 postMessage/snapshot 可被 mock client 收到 (covers: S2) +- [ ] T2: `tabs.html` 启动 Owner — acceptance: 打开外壳后 BC 上出现 esp32/ecat 快照,且关页后不再刷 (covers: S2) +- [ ] T3: OTA 页连接态改读 DeviceLink(独立打开回退) — acceptance: 外壳内 OTA 与 LAN9252 的「在线/离线」一致;LAN9252 SPI 失败不再显示设备离线 (covers: S2) +- [ ] T4: LAN9252 页灯与文案接入 — acceptance: 同 (covers: S2) +- [ ] T5: BLE GATT 状态上报 — acceptance: BLE 连上后快照 ble.online=true,其它页可观察 (covers: S2) +- [ ] T6: 语法检查 + 评审 — acceptance: node --check / 人工路径说明写入 Report (covers: S2) diff --git a/js/ble/gatt.js b/js/ble/gatt.js index d8e104c..45ad39a 100644 --- a/js/ble/gatt.js +++ b/js/ble/gatt.js @@ -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, @@ -104,6 +105,7 @@ export async function connectBle() { setBleStage(2, '已就绪', null); setConnectionState(true); + try { deviceLink.reportBle(true, device.name || ''); } catch (_) {} bleLog('BLE', 'GATT 服务与特征获取完成'); await enableNotifications(); // ★ enableNotifications 内部已包含重试 + 主动断开逻辑; @@ -129,6 +131,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 +154,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(); } diff --git a/js/common/connection.js b/js/common/connection.js index 7795986..41899f9 100644 --- a/js/common/connection.js +++ b/js/common/connection.js @@ -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,15 @@ 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) { + connected = !!snap.links.esp32.online; + text = connected ? '已连接' : '离线'; + } + } catch (_) {} var label = text || (connected ? '已连接' : '未连接'); // 仅顶栏连接胶囊(下方连接卡不再重复显示文案) var pc = getEl('pillConn'); diff --git a/js/common/device-link.js b/js/common/device-link.js new file mode 100644 index 0000000..9cdb3d8 --- /dev/null +++ b/js/common/device-link.js @@ -0,0 +1,263 @@ +/** + * device-link.js — 全链路设备健康层(外壳 Owner + 功能页 Client) + * + * 问题:各页各自 fetch,只共用 IP 字符串 → 一页「已连接」一页「失败」。 + * 方案:tabs.html 常驻 Owner 轮询 ESP32 /system/status 与 EtherCAT /api/status + * + /api/app/load,经 BroadcastChannel('dcu-device-link') 广播快照; + * 功能页只订阅渲染。业务命令(SPI/CAN/OTA)仍页内发,失败不得把 + * 「设备离线」灯打红——灯只认本层。 + * + * 角色: + * - Owner:tabs.html 在加载本文件前设 window.__dcuDeviceLinkOwner = true + * - Client:任意页 import { deviceLink } + * 独立直开页面(无外壳)时 client 收不到 snapshot,可退回本地轮询。 + */ +"use strict"; + +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"; + +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; +} + +function writeIp(ip) { + if (!ip || !String(ip).trim()) return; + try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {} +} + +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; + + function publish() { + snap.ts = nowMs(); + snap.ip = readIp(); + if (ch) { + try { ch.postMessage({ type: "snapshot", snap: snap }); } catch (_) {} + } + /* 同源 iframe 也可读 window 顶层缓存(BC 不可用时的弱备份) */ + try { window.__dcuDeviceLinkSnap = 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 ip = readIp(); + var ac = null; + try { ac = new AbortController(); } catch (_) {} + var tmo = null; + fetch("http://" + ip + "/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); + } + } + + 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; }); + + 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 = e.online; + /* native status 不直接给 op/loop;用 load valid + slave state 粗判 */ + e.op = !!(st && st.slave && (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; + publish(); + }).then(function () { + 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); + /* 立刻重拉 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); }); + } 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(); + 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; + try { last = window.__dcuDeviceLinkSnap || null; } catch (_) {} + + 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; + try { window.__dcuDeviceLinkSnap = last; } catch (_) {} + emit(); + }); + try { ch.postMessage({ type: "hello" }); } catch (_) {} + } + + return { + /** 当前快照(可能为 null,Owner 尚未广播时) */ + 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); + }; + }, + setIp: function (ip) { + writeIp(ip); + if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} } + }, + getIp: function () { return readIp(); }, + reportBle: function (online, deviceName) { + var payload = { + type: "ble-state", + online: !!online, + deviceName: deviceName || "", + 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(); + } + }, + /** 是否有外壳 Owner 在管(收到过 snapshot 或自身是 owner) */ + hasOwner: function () { return !!last; } + }; +} + +/* ───────────────────────── bootstrap ───────────────────────── */ + +var deviceLink; +try { + if (window.__dcuDeviceLinkOwner) { + window.__dcuDeviceLinkInstance = createOwner(); + } +} catch (_) {} + +deviceLink = createClient(); +export { deviceLink }; +export default deviceLink; diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html index 29c3689..74c4876 100644 --- a/lab/lan9252_pm.html +++ b/lab/lan9252_pm.html @@ -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,11 +327,6 @@ * 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 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; - } function bumpPollGen() { return ++pollGen; } /* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */ function markAwake() { @@ -336,7 +356,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 +379,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 +393,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); }); } @@ -604,5 +622,14 @@ } })(); + diff --git a/tabs.html b/tabs.html index 254b5f3..f5dbee2 100644 --- a/tabs.html +++ b/tabs.html @@ -179,5 +179,8 @@ + + + From 6b5a9f9adc2b1ae0652ba8b5ac013734874928bd Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 02:53:53 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(link):=20ECAT=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E5=BF=85=E9=87=8D=E6=8C=82=EF=BC=9B=E8=BF=9E=E6=8E=A5=E7=81=AF?= =?UTF-8?q?=E4=B8=8D=E8=A6=86=E7=9B=96=E8=AF=AD=E4=B9=89=E6=96=87=E6=A1=88?= =?UTF-8?q?=EF=BC=9BBLE=20=E9=80=9A=E7=9F=A5=E6=88=90=E5=8A=9F=E5=90=8E?= =?UTF-8?q?=E5=86=8D=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/ble/gatt.js | 4 +++- js/common/connection.js | 3 ++- js/common/device-link.js | 10 ++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/js/ble/gatt.js b/js/ble/gatt.js index 45ad39a..25282e9 100644 --- a/js/ble/gatt.js +++ b/js/ble/gatt.js @@ -105,19 +105,21 @@ export async function connectBle() { setBleStage(2, '已就绪', null); setConnectionState(true); - try { deviceLink.reportBle(true, device.name || ''); } catch (_) {} bleLog('BLE', 'GATT 服务与特征获取完成'); await enableNotifications(); // ★ 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); diff --git a/js/common/connection.js b/js/common/connection.js index 41899f9..5b7d2d5 100644 --- a/js/common/connection.js +++ b/js/common/connection.js @@ -61,8 +61,9 @@ export function updateConnectionUi(connected, text) { try { var snap = deviceLink.getSnapshot(); if (snap && snap.ts && (Date.now() - snap.ts) < 5000) { + /* 只覆盖在线布尔,保留调用方语义文案(设备忙/诊断中/STA…) */ connected = !!snap.links.esp32.online; - text = connected ? '已连接' : '离线'; + if (!text) text = connected ? '已连接' : '离线'; } } catch (_) {} var label = text || (connected ? '已连接' : '未连接'); diff --git a/js/common/device-link.js b/js/common/device-link.js index 9cdb3d8..c7ca260 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -68,8 +68,6 @@ function createOwner() { if (ch) { try { ch.postMessage({ type: "snapshot", snap: snap }); } catch (_) {} } - /* 同源 iframe 也可读 window 顶层缓存(BC 不可用时的弱备份) */ - try { window.__dcuDeviceLinkSnap = snap; } catch (_) {} } function setEsp32(status, ok) { @@ -133,15 +131,17 @@ function createOwner() { e.online = !!(st && st.ok && st.slave); e.busOpen = e.online; /* native status 不直接给 op/loop;用 load valid + slave state 粗判 */ - e.op = !!(st && st.slave && (st.slave.state & 0x0F) === 8); + 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; publish(); + }).catch(function () { + /* 字段访问/异常不得让 ECAT 轮询永久停摆 */ }).then(function () { - ecatTimer = setTimeout(pollEcat, ECAT_POLL_MS); + if (!stopped) ecatTimer = setTimeout(pollEcat, ECAT_POLL_MS); }); } @@ -190,7 +190,6 @@ function createClient() { try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; } var listeners = []; var last = null; - try { last = window.__dcuDeviceLinkSnap || null; } catch (_) {} function emit() { for (var i = 0; i < listeners.length; i++) { @@ -203,7 +202,6 @@ function createClient() { var m = ev && ev.data; if (!m || m.type !== "snapshot" || !m.snap) return; last = m.snap; - try { window.__dcuDeviceLinkSnap = last; } catch (_) {} emit(); }); try { ch.postMessage({ type: "hello" }); } catch (_) {} From 353d936abb9ce3861d96ba00c6923e592f521f50 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 02:54:08 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs(compose):=20device-link=20=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=20delivered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/compose/spec/device-link.md | 109 ++++++++++++------------------- 1 file changed, 42 insertions(+), 67 deletions(-) diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md index 627cf32..4d608fd 100644 --- a/docs/compose/spec/device-link.md +++ b/docs/compose/spec/device-link.md @@ -1,18 +1,37 @@ --- feature: device-link -status: designed +status: delivered updated: 2026-09-16 branch: feat/device-link -commits: +commits: be26f4daa27cba70b4baeaeb3865f38ed51b2e71..6b5a9f9 --- # Device Link(全链路统一连接层) ## 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。 + +**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 + +**Journey log** — +- 「共用 IP」≠「共用连接」——健康态必须有单一 Owner +- 外壳 Owner + 页内 Client 双 BC 实例符合 BroadcastChannel 语义(同上下文不自发自收) +- SPI/业务失败与设备离线必须分文案,否则子系统故障会伪装成掉线 +- ECAT 轮询的 reschedule 必须在 `.catch` 之后,否则一次异常永久停摆 + ## [S1] Problem -多 iframe 保活后,各页仍**各自** `fetch` 设备,只是共用 `localStorage.dcu_sp_ip`: +多 iframe 保活后,各页仍**各自** `fetch` 设备,只是共用 `localStorage.dcu_esp_ip`: - OTA 页轮询 `/system/status` → 顶栏「WiFi 已连接」 - LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败 · signal timed out」 @@ -20,7 +39,6 @@ commits: - BLE 在页内 GATT,其它页完全不知 用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话。 -结果:一页绿灯一页红灯,像系统坏了,其实是**健康态没有单一真相源**。 ## [S2] Design @@ -28,78 +46,35 @@ commits: 1. **外壳持有链路健康轮询**(tabs.html 常驻),功能页不再各自判定「设备在线」。 2. **BroadcastChannel**(`dcu-device-link`)广播快照;页内只订阅渲染。 -3. **命令仍页内发**(SPI 0xA2、CAN send、OTA upload 等),但**连接灯只认 DeviceLink**。 -4. SPI/业务命令失败显示为 **子系统故障**(如「SPI 超时」),不得覆盖成「设备离线」——除非 DeviceLink 已判 ESP32 离线。 -5. 页面可**独立打开**(无外壳):检测不到 owner 时回退本地轮询,行为与改前一致。 +3. **命令仍页内发**,但**连接灯只认 DeviceLink**(布尔优先,文案保留调用方语义)。 +4. SPI 失败 →「设备在线 · SPI 失败」;DeviceLink offline 才显示离线。 +5. 独立直开页:无快照时回退本地轮询。 ### 模块 -`js/common/device-link.js` 同一文件双角色: +`js/common/device-link.js` Owner+Client;消息:`hello` / `snapshot` / `set-ip` / `ble-state`。 -| 角色 | 谁加载 | 职责 | -|------|--------|------| -| Owner | `tabs.html`(`window.__dcuDeviceLinkOwner = true`) | 轮询 ESP32 `/system/status`、ECAT `/ecat/api/status`+`/api/app/load`;维护快照并广播 | -| Client | 各功能页 | `subscribe()` 收快照;`setIp()`;`reportBle()` 上报 GATT 状态 | +### 接入 -### 快照契约 - -```js -{ - ts: 1710000000000, - ip: "192.168.31.127", - links: { - esp32: { online: boolean, ageMs: number, failStreak: number }, - ecat: { online: boolean, busOpen: boolean, op: boolean, loop: boolean, - loadValid: boolean, loadPerm: number, ageMs: number }, - ble: { online: boolean, deviceName: string, owner: string|null } - }, - esp32Status: object|null // 最近一次 /system/status 全文(可 null) -} -``` - -### 消息 - -| type | 方向 | 语义 | -|------|------|------| -| `hello` | Client→Owner | 请求立刻回一份 snapshot | -| `snapshot` | Owner→All | 全量快照(ESP32 状态变化或 1s 心跳时发) | -| `set-ip` | Client→Owner | 改设备 IP;Owner 写 localStorage 并立刻重拉 | -| `ble-state` | Client→Owner | `{online, deviceName}` GATT 连/断 | - -Owner 用 `BroadcastChannel`;无 BC 的环境 client 降级为「仅 localStorage 读 IP + 本地轮询」。 - -### 接入点(本轮) - -| 页面/模块 | 改动 | -|-----------|------| -| `tabs.html` | 置 owner 标志并 `import` device-link 启动 | -| `pages/index.html` + `js/common/polling.js`/`connection.js` | 有 DeviceLink 时:连接胶囊/健康读快照;`pollStatus` 仍可保留作数据源或改为消费 `esp32Status`(见任务) | -| `lab/lan9252_pm.html` | 连接灯改读 DeviceLink;`/spi/send` 失败文案改为「SPI 失败」 | -| `js/ble/gatt.js` 或 session | connect/disconnect 时 `reportBle` | -| `js/ota/ecat_load.js` | 可选:owner 已有 load 时页内 poller 可只读快照(本轮先并存,避免双写 UI) | - -### 超时与判定 - -- ESP32:成功 → `online=true, failStreak=0`;连续失败 ≥3 → `online=false` -- ECAT:`/api/status` ok 且 slave 存在 → 轨道 `busOpen`;`/app/load` `valid && age_ms≤3000` → load 新鲜 -- BLE:仅 GATT 主人上报;无上报时 `online=false` - -### 与 keep-alive 的关系 - -DeviceLink 跑在**外壳**,与 iframe 是否可见无关;隐藏页仍能显示统一灯(打开即亮)。 +| 位置 | 行为 | +|------|------| +| `tabs.html` | Owner | +| `connection.js` | 快照新鲜时覆盖 connected 布尔 | +| `lan9252_pm.html` | 订阅灯 + `setSpiFail` | +| `js/ble/gatt.js` | 通知成功后 reportBle(true),失败/断 false | ## [S3] Out of Scope -- 不把 SPI/CAN/OTA 命令全部塞进共享队列(串行化另开) +- 不串行化 SPI/CAN/OTA 命令队列 - 不迁 SharedWorker -- 不改 ESP32/ECAT 固件协议 -- 不合并 BLE GATT 到外壳(本轮只广播状态) +- 不改固件协议 +- BLE GATT 仍留在页内(只广播状态) ## Tasks -- [ ] T1: `device-link.js` Owner+Client + BroadcastChannel 协议 — acceptance: 单测式手工:外壳起后 postMessage/snapshot 可被 mock client 收到 (covers: S2) -- [ ] T2: `tabs.html` 启动 Owner — acceptance: 打开外壳后 BC 上出现 esp32/ecat 快照,且关页后不再刷 (covers: S2) -- [ ] T3: OTA 页连接态改读 DeviceLink(独立打开回退) — acceptance: 外壳内 OTA 与 LAN9252 的「在线/离线」一致;LAN9252 SPI 失败不再显示设备离线 (covers: S2) -- [ ] T4: LAN9252 页灯与文案接入 — acceptance: 同 (covers: S2) -- [ ] T5: BLE GATT 状态上报 — acceptance: BLE 连上后快照 ble.online=true,其它页可观察 (covers: S2) -- [ ] T6: 语法检查 + 评审 — acceptance: node --check / 人工路径说明写入 Report (covers: S2) +- [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) From 033ece2cca427dd870728bccc13f92a08e54f838 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:12:24 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(link):=20ConnectionHub=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20WiFi/ECAT/BLE=20=E7=AB=AF=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各页不再各自读写 dcu_esp_ip / dcu_ecat_adapter:deviceLink 提供 wifiBaseUrl/ecatPost/adapter API,OTA/CAN/motor/ecat_load/lab 走同一入口。 连接灯只覆盖在线布尔,保留调用方语义文案。 --- docs/compose/spec/device-link.md | 73 +++++++++------ js/app.js | 14 +-- js/can.js | 7 ++ js/can/dbc.js | 4 +- js/can_monitor.js | 30 ++++--- js/common/device-link.js | 148 +++++++++++++++++++++---------- js/motor/main.js | 19 +++- js/motor/transport-ecat.js | 13 +-- js/ota/ecat_load.js | 6 +- lab/lan9252_pm.html | 4 +- lab/spi_test.html | 2 +- 11 files changed, 210 insertions(+), 110 deletions(-) diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md index 4d608fd..b3cb36c 100644 --- a/docs/compose/spec/device-link.md +++ b/docs/compose/spec/device-link.md @@ -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) diff --git a/js/app.js b/js/app.js index 98a10a3..9135900 100644 --- a/js/app.js +++ b/js/app.js @@ -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) { diff --git a/js/can.js b/js/can.js index c3c3ee0..52beb77 100644 --- a/js/can.js +++ b/js/can.js @@ -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(); } diff --git a/js/can/dbc.js b/js/can/dbc.js index da70de7..994de84 100644 --- a/js/can/dbc.js +++ b/js/can/dbc.js @@ -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; } diff --git a/js/can_monitor.js b/js/can_monitor.js index 48d9ee2..5983ce4 100644 --- a/js/can_monitor.js +++ b/js/can_monitor.js @@ -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 与发送台共用 */ diff --git a/js/common/device-link.js b/js/common/device-link.js index c7ca260..983bb37 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -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} 解析后的 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; diff --git a/js/motor/main.js b/js/motor/main.js index 12ba0d9..04655d0 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -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(); diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 936a1ce..9917d3e 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -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 (_) { /* 没连接时无所谓 */ } diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js index 9a02518..77240d1 100644 --- a/js/ota/ecat_load.js +++ b/js/ota/ecat_load.js @@ -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++; diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html index 74c4876..74ffd64 100644 --- a/lab/lan9252_pm.html +++ b/lab/lan9252_pm.html @@ -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); }); diff --git a/lab/spi_test.html b/lab/spi_test.html index d784f75..96254e5 100644 --- a/lab/spi_test.html +++ b/lab/spi_test.html @@ -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) { From 0629bf0910274c0f086a4c5c7425ac73746c2db3 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:12:36 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(compose):=20device-link=20ConnectionHu?= =?UTF-8?q?b=20=E6=89=A9=E5=B1=95=E8=AE=B0=E5=85=A5=E8=A7=84=E6=A0=BC?= =?UTF-8?q?=E4=B8=8E=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/compose/spec/device-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md index b3cb36c..111a6f0 100644 --- a/docs/compose/spec/device-link.md +++ b/docs/compose/spec/device-link.md @@ -3,7 +3,7 @@ feature: device-link status: delivered updated: 2026-09-16 branch: feat/device-link -commits: 353d936..HEAD +commits: 353d936..033ece2 --- # Device Link / ConnectionHub(全链路统一连接层) From 4b4559e772c6066aff1ae7a28dffa5acd218fa63 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:19:55 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(server):=20worktree=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=20ECAT=5FDIR=20=E5=9B=9E=E6=8C=87=E4=B8=BB?= =?UTF-8?q?=E6=A3=80=E5=87=BA=20tools/ecat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 .worktrees/ 跑 esp32_server 时 join(BASE_DIR,'..','ecat') 会拼成 .worktrees/ecat,找不到 ecat_native.exe。先走出 .worktrees 再取兄弟目录。 --- esp32_server.py | 16 ++++++++- scripts/test_ecat_dir_worktree.mjs | 57 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 scripts/test_ecat_dir_worktree.mjs diff --git a/esp32_server.py b/esp32_server.py index f8d656a..50620df 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -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/)启动时,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 内注释) diff --git a/scripts/test_ecat_dir_worktree.mjs b/scripts/test_ecat_dir_worktree.mjs new file mode 100644 index 0000000..a4b5e1f --- /dev/null +++ b/scripts/test_ecat_dir_worktree.mjs @@ -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 项"); From f9502579ef67f365261dccd402a1f98bccc9455b Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:24:45 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(link):=20=E5=A4=96=E5=A3=B3=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=BC=BA=E5=88=B6=20open+motor/start=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=BF=85=E7=AD=89=E7=94=B5=E6=9C=BA=E9=A1=B5=E5=88=87?= =?UTF-8?q?=20EtherCAT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeviceLink Owner 在控制台加载后自动 bootstrapEcatBus:status→open→ motor/start,沿用 hub 网卡与 period/spin 持久化。成功后不重复 start; 掉线 10s 退避重开。有线 ECAT 与 WiFi 不冲突。 --- docs/compose/spec/device-link.md | 3 ++ js/common/device-link.js | 58 +++++++++++++++++++++++++++++++- js/ota/ecat_load.js | 4 +-- tabs.html | 2 +- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md index 111a6f0..2ea96d1 100644 --- a/docs/compose/spec/device-link.md +++ b/docs/compose/spec/device-link.md @@ -48,6 +48,8 @@ can_monitor、motor、ecat_load、LAN9252 改为从 Hub 读端点/订在线布 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`) @@ -66,6 +68,7 @@ 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 | diff --git a/js/common/device-link.js b/js/common/device-link.js index 983bb37..190f1ac 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -23,6 +23,9 @@ 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"; @@ -104,6 +107,11 @@ function createOwner() { 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(); @@ -155,6 +163,50 @@ function createOwner() { } } + /** + * 开总线: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; }); @@ -164,7 +216,7 @@ function createOwner() { var st = arr[0], ld = arr[1]; var e = snap.links.ecat; e.online = !!(st && st.ok && st.slave); - e.busOpen = e.online; + 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 @@ -172,7 +224,10 @@ function createOwner() { 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 () { @@ -208,6 +263,7 @@ function createOwner() { if (ch) ch.addEventListener("message", onMsg); pollEsp32(); pollEcat(); + bootstrapEcatBus(); publish(); return { diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js index 77240d1..62ffdc1 100644 --- a/js/ota/ecat_load.js +++ b/js/ota/ecat_load.js @@ -41,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) { diff --git a/tabs.html b/tabs.html index f5dbee2..bfb840e 100644 --- a/tabs.html +++ b/tabs.html @@ -181,6 +181,6 @@ - + From 6f1b3c2706e405d91d42d3507ed4cd4be17b6f57 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:26:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(pm):=20pmHold=20=E5=B9=82=E7=AD=89?= =?UTF-8?q?=EF=BC=8C=E6=B8=85=E9=86=92=E8=BD=AE=E8=AF=A2=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=AF=8F=202s=20=E6=89=93=E3=80=8C=E8=B1=81=E5=85=8D=E8=A7=A3?= =?UTF-8?q?=E9=99=A4=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pollOnce 每 2s 调 markAwake();原先无脑 pmHold(0) 会重复 POST 并刷日志。 现跟踪 pmHoldOn,仅在状态翻转时发请求并记一行。 --- lab/lan9252_pm.html | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html index 74ffd64..90039cb 100644 --- a/lab/lan9252_pm.html +++ b/lab/lan9252_pm.html @@ -327,11 +327,15 @@ * 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。 * pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */ var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0; + /** 主站看门狗豁免是否已置位(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 通信已中断,点「手动唤醒」恢复"; } @@ -431,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() {