feat(link): DeviceLink 外壳单例统一 ESP32/ECAT/BLE 健康态

各页只共用 IP、各自 fetch 导致同屏「已连接/连接失败」分裂。
外壳 Owner 轮询 /system/status 与 EtherCAT 状态,BroadcastChannel
广播快照;连接灯只认共享层,SPI 失败单独文案不覆盖设备离线。
This commit is contained in:
cbb
2026-09-16 02:50:12 +08:00
parent be26f4daa2
commit 670b833dc6
6 changed files with 422 additions and 10 deletions
+105
View File
@@ -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)
+4
View File
@@ -33,6 +33,7 @@ import {
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js'; import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
import { setProgress, updateProgressFromAck } from './transfer.js'; import { setProgress, updateProgressFromAck } from './transfer.js';
import { showBleVerifyInfo } from './verify.js'; import { showBleVerifyInfo } from './verify.js';
import { deviceLink } from '../common/device-link.js';
import { import {
clearOtaResultWaiter, clearOtaResultWaiter,
resolveOtaResult, resolveOtaResult,
@@ -104,6 +105,7 @@ export async function connectBle() {
setBleStage(2, '已就绪', null); setBleStage(2, '已就绪', null);
setConnectionState(true); setConnectionState(true);
try { deviceLink.reportBle(true, device.name || ''); } catch (_) {}
bleLog('BLE', 'GATT 服务与特征获取完成'); bleLog('BLE', 'GATT 服务与特征获取完成');
await enableNotifications(); await enableNotifications();
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑; // ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
@@ -129,6 +131,7 @@ export async function connectBle() {
export function onDisconnected() { export function onDisconnected() {
bleLog('BLE', '设备已断开', 'W'); bleLog('BLE', '设备已断开', 'W');
var wasExpected = state.expectedReboot; var wasExpected = state.expectedReboot;
try { deviceLink.reportBle(false, ''); } catch (_) {}
resetUiAfterDisconnect(); resetUiAfterDisconnect();
if (wasExpected && state.device) { if (wasExpected && state.device) {
// 是主动重启引起的断开,启动自动重连 // 是主动重启引起的断开,启动自动重连
@@ -151,6 +154,7 @@ export async function disconnectBle() {
if (state.device && state.device.gatt && state.device.gatt.connected) { if (state.device && state.device.gatt && state.device.gatt.connected) {
state.device.gatt.disconnect(); state.device.gatt.disconnect();
} }
try { deviceLink.reportBle(false, ''); } catch (_) {}
resetUiAfterDisconnect(); resetUiAfterDisconnect();
} }
+10
View File
@@ -18,6 +18,7 @@ import { showToast } from './toast.js';
import { import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js'; } from './heap-low-history.js';
import { deviceLink } from './device-link.js';
// ==================== 渲染缓存(性能) ==================== // ==================== 渲染缓存(性能) ====================
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建 // /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
@@ -55,6 +56,15 @@ function setWidthIfChanged(el, widthStr) {
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) { 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 label = text || (connected ? '已连接' : '未连接');
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案) // 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = getEl('pillConn'); var pc = getEl('pillConn');
+263
View File
@@ -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;
+37 -10
View File
@@ -186,6 +186,31 @@
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
var logBox = $("log"); 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,不设独立连接区 */ /* 与 js/common/base-url.js 同 key 同默认值:全局唯一设备 IP,不设独立连接区 */
var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127"; var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127";
function deviceIp() { function deviceIp() {
@@ -302,11 +327,6 @@
* 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。 * 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。
* pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */ * pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */
var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0; 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; } function bumpPollGen() { return ++pollGen; }
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */ /* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
function markAwake() { function markAwake() {
@@ -336,7 +356,7 @@
if (gen !== pollGen || pollPaused) return; if (gen !== pollGen || pollPaused) return;
pollFail++; pollFail++;
if (pollFail >= 3) { if (pollFail >= 3) {
setConn(false, "连接失败 · 5s 后重试"); setSpiFail("SPI 轮询失败 ×3(IP " + deviceIp() + ")· 5s 后重试");
pollTimer = setTimeout(probe, 5000); pollTimer = setTimeout(probe, 5000);
} else { } else {
pollTimer = setTimeout(pollOnce, 2000); pollTimer = setTimeout(pollOnce, 2000);
@@ -359,8 +379,7 @@
log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok"); log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok");
}) })
.catch(function (e) { .catch(function (e) {
setConn(false, "连接失败 · 5s 后重试"); setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probeEspOnly, 5000); pollTimer = setTimeout(probeEspOnly, 5000);
}); });
} }
@@ -374,8 +393,7 @@
pollOnce(); pollOnce();
}) })
.catch(function (e) { .catch(function (e) {
setConn(false, "连接失败 · 5s 后重试"); setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probe, 5000); pollTimer = setTimeout(probe, 5000);
}); });
} }
@@ -604,5 +622,14 @@
} }
})(); })();
</script> </script>
<script type="module">
import { deviceLink } from '../js/common/device-link.js?v=20260916a';
window.__pmDeviceLink = deviceLink;
deviceLink.subscribe(function (s) {
if (!s || !s.links || !window.__pmSetConn) return;
var e = s.links.esp32;
window.__pmSetConn(!!e.online, e.online ? '已连接' : '离线');
});
</script>
</body> </body>
</html> </html>
+3
View File
@@ -179,5 +179,8 @@
<!-- app 窗口租约: 本外壳窗口活着期间保持与本地服务的连接, <!-- app 窗口租约: 本外壳窗口活着期间保持与本地服务的连接,
全部窗口关闭后服务端自动停 EtherCAT PDO 循环(见 js/common/app-lease.js) --> 全部窗口关闭后服务端自动停 EtherCAT PDO 循环(见 js/common/app-lease.js) -->
<script src="js/common/app-lease.js"></script> <script src="js/common/app-lease.js"></script>
<!-- DeviceLink Owner:全链路健康轮询只在本外壳跑一次,各 iframe 订阅 -->
<script>window.__dcuDeviceLinkOwner = true;</script>
<script type="module" src="js/common/device-link.js?v=20260916a"></script>
</body> </body>
</html> </html>