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] =?UTF-8?q?feat(link):=20DeviceLink=20=E5=A4=96=E5=A3=B3?= =?UTF-8?q?=E5=8D=95=E4=BE=8B=E7=BB=9F=E4=B8=80=20ESP32/ECAT/BLE=20?= =?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 各页只共用 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 @@ + + +