ESP32 智能插座 Web 上位机设计与实现|原生 JavaScript 无框架,计量采集、调试、设备管理
摘要
本文实现一套基于原生 JavaScript 的 ESP32 智能插座网页上位机,不引入任何前端第三方库。上位机通过本地桥接服务与 ESP32 设备完成 TCP/UDP 通信,实现局域网设备发现、继电器开关控制、电压 / 电流 / 有功功率 / 累计电能实时计量、功率曲线绘制、SN 与产品型号配置、电能清零、计量校准、OTA 固件升级、收发报文日志调试等完整功能。
针对硬件协议存在的边界问题:switch get查询开关状态时硬件返回应答method=info,代码保留原有switch协议解析分支,不对原有协议分支做兼容修改,区分桥接服务下发状态与设备真实应答,增加分层日志与 Toast 提示,便于定位指令下发、设备应答链路问题。
项目文件清单
index.html:页面 DOM 结构,UI 组件定义app.js:业务逻辑、协议解析、交互事件全部实现style.css:页面样式,深色调试主题
一、整体架构设计
整体分为三层:网页上位机 → 本地桥接服务 → ESP32 智能插座设备
- 网页端:纯前端实现,通过
fetch调用桥接服务暴露的 HTTP 接口;不直接和 ESP32 建立 TCP 连接,浏览器跨域与网络权限问题交由桥接层处理。 - 桥接服务:中间转发层,提供 HTTP API,内部完成 UDP 广播设备搜索、TCP 连接 ESP32、指令下发、设备报文接收、日志缓存。
- ESP32 设备:执行控制、BL09xx 计量芯片采集,按照自定义 JSON 协议返回应答报文。
桥接服务接口定义:
表格
| 接口 | 请求方式 | 说明 |
|---|---|---|
/api/connect |
POST | 建立与目标 ESP32 设备 TCP 连接,入参ip、port |
/api/cmd |
POST | 下发 JSON 格式控制指令到设备 |
/api/poll?after=seq |
GET | 轮询获取设备增量日志、应答报文,seq 为已消费序号 |
/api/scan |
GET | UDP 广播扫描局域网内插座设备 |
本文只提供网页上位机完整源码,桥接服务需要开发者自行实现。
二、核心代码逻辑解析
2.1 全局状态管理
使用常量对象S维护全局运行状态,避免零散全局变量,状态含义:
const S = {
seq: 0, // 日志消费序号,poll接口增量拉取日志
dataPollOn: true, // 实时计量数据轮询开关
lastSwitch: null, // 缓存设备继电器开关状态 0/1
lastData: null, // 缓存最新一组计量数据 V/I/P
seenReplies: new Set(), // 应答报文去重集合,防止重复处理同一设备返回报文
powerBuf: [], // 功率历史数组,用于Canvas绘制功率曲线
};
seq:轮询接口/api/poll传入参数,每次拉取之后更新为服务端返回next值,实现增量获取日志,避免日志重复读取。seenReplies:使用 Set 存储原始应答报文,设备重复推送报文时过滤重复处理,集合设置最大容量,防止内存持续上涨。powerBuf:限定最大 60 个数据点,超出长度丢弃最早数据,保证曲线只展示最近一段时间功率。
2.2 指令发送封装 sendCmdWithNotify
原始代码直接调用sendCmd()发送指令,调试时无法区分:指令是否发给桥接、桥接是否转发成功、设备是否返回应答。 新增封装函数统一处理下发流程:
- UI 弹出 Toast 提示正在下发指令;
- 日志输出
[SEND_CMD]记录下发指令名称与 JSON 报文; - 调用底层
sendCmd向桥接发送 HTTP 请求; - 根据桥接返回结果打印
[SEND_OK]/[SEND_FAIL]并更新 Toast;
⚠️重要:桥接返回 ok,仅代表桥接服务收到并转发指令,不等于设备已经执行并返回应答 ;设备执行结果需要等待 poll 轮询拿到设备应答报文,在
handleReply中处理。
async function sendCmdWithNotify(cmdObj, cmdName) {
toast(`正在下发【${cmdName}】指令`,"");
addLog(`[SEND_CMD] ${cmdName} : ${JSON.stringify(cmdObj)}`);
const j = await sendCmd(cmdObj);
if(j && j.ok){
toast(`【${cmdName}】指令下发成功(桥接已接收)`, "ok");
addLog(`[SEND_OK] ${cmdName} 桥接下发完成,等待设备应答`);
}else{
toast(`【${cmdName}】指令下发失败`,"err");
addLog(`[SEND_FAIL] ${cmdName} 下发失败 `+(j?.error||""));
}
return j;
}
底层sendCmd负责 HTTP 请求桥接接口,捕获网络异常,网络断开时给出 toast 提示。
2.3 轮询机制 poll
使用setInterval(poll,2000)定时轮询桥接服务:
- 获取桥接连接状态,更新页面连接指示灯;
- 根据
seq增量拉取设备运行日志,调用addLog渲染日志面板; - 获取设备应答报文数组,循环调用
handleReply(rp)完成报文分发解析。
2.4 报文解析分发 handleReply
这是协议解析核心函数。对设备返回 JSON 应答做分支匹配。
业务背景:硬件
switch get查询开关状态时返回应答method=info,代码不修改原有 switch 分支逻辑,保持协议定义原貌。 如果开关状态无法更新,代表硬件应答报文 method 字段与协议定义不符,需要固件侧修复。
function handleReply(rp) {
//应答去重
if (S.seenReplies.has(rp.raw)) return;
S.seenReplies.add(rp.raw);
if (S.seenReplies.size > 30) S.seenReplies.delete(S.seenReplies.values().next().value);
addReply(rp.raw);
const obj = rp.obj, method = obj.method, payload = obj.payload || {};
const para = payload.para;
//计量数据返回 method=info type=data,解析V/I/P/EP总电能
if (method === "info" && payload.type === "data" && para) {
S.lastData = para;
updateMeters(para);
}
//【原始协议分支,不做修改】开关状态应答 method必须为switch
else if (method === "switch" && typeof payload.state !== "undefined") {
S.lastSwitch = payload.state;
updateSwitch(payload.state);
}
//设备系统信息 SN、Product
else if (method === "info" && payload.type === "sys" && para) {
fillInfo(para);
}
//清除电能成功应答
else if (method === "energy" && obj.res === 1) {
toast("电能清除成功【设备应答】", "ok");
addLog("[DEVICE_REPLY] 清除电能:设备应答成功");
$("mep").textContent = "--.---";
}
//校准、OTA、SN设置等其余应答分支省略
}
日志规范:设备真实应答统一打印标签[DEVICE_REPLY],与桥接下发日志区分,调试时可以快速定位报文链路问题。
2.5 计量数据渲染 updateMeters
新增解析para.EP累计电能 (kWh) 字段,更新页面 4 个计量显示项:电压、电流、功率、总电能。功率数值存入powerBuf数组,调用 Canvas 绘制功率曲线。
function updateMeters(para) {
$("mv").textContent = (para.V !== undefined) ? para.V.toFixed(1) : "--.-";
$("mi").textContent = (para.I !== undefined) ? para.I.toFixed(3) : "--.--";
$("mp").textContent = (para.P !== undefined) ? para.P.toFixed(1) : "--.-";
$("mep").textContent = (para.EP !== undefined) ? para.EP.toFixed(3) : "--.---";
if (typeof para.P === "number") {
S.powerBuf.push(para.P);
if (S.powerBuf.length > 60) S.powerBuf.shift();
drawChart();
}
}
定时任务每秒下发info get type=data获取实时计量数据,支持暂停 / 恢复轮询。
2.6 设备发现逻辑 doScan
调用桥接/api/scan接口执行 UDP 广播扫描局域网设备;渲染设备列表,点击设备自动填充 IP、端口,触发连接。
2.7 Canvas 功率曲线 drawChart
原生 Canvas 实现曲线绘制,无图表库依赖。
- 清空画布,绘制横向网格辅助线;
- 获取功率数组最大、最小值做坐标归一化;
- 逐点计算 XY 坐标绘制折线;
- 数据不足两点不绘图。
2.8 UI 交互
- 开关按钮:读取内存缓存
S.lastSwitch,点击取反下发 switch set 指令;每 2 秒轮询switch get查询开关状态。 - SN&Product 配置:支持读取设备系统信息,同时写入 SN、Product 参数。
- 电能清零:下发
energy cmd:clear指令,收到设备成功应答后重置页面电能显示。 - 校准流程:校准初始化按钮激活执行校准按钮,弹出确认框提示外接标准负载。
- OTA 升级:传入固件 HTTP 地址,下发 OTA 升级指令。
- 手动命令面板:支持输入 JSON 报文直接下发,回车快捷发送,展示原始设备应答报文。
- 日志模块:区分普通、警告、错误日志颜色,限制 DOM 节点数量防止页面卡顿,支持自动滚动。
三、完整源码
3.1 index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 智能插座 上位机</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<h1>⚡ ESP32 智能插座 调试台</h1>
<div class="conn">
<input id="ip" placeholder="设备 IP" title="设备 IP">
<input id="port" placeholder="端口" value="58888" title="设备调试端口">
<button id="btnConnect" class="primary">连接</button>
<button id="btnScan" class="secondary">📡 搜索设备...</button>
<span class="conn-status">
<span id="connDot" class="dot off"></span>
<span id="connText">未连接</span>
</span>
</div>
</header>
<main class="container">
<section class="card">
<h2>📊 实时计量数据</h2>
<div class="meter-row">
<div class="meter-item">电压(V):<span id="mv">--.-</span></div>
<div class="meter-item">电流(A):<span id="mi">--.--</span></div>
<div class="meter-item">功率(W):<span id="mp">--.-</span></div>
<div class="meter-item">总电能(kWh):<span id="mep">--.---</span></div>
</div>
<div class="ctrl-row">
<button id="btnDataPoll">暂停</button>
<button id="btnClearEnergy">清除电能累计</button>
</div>
<canvas id="chart" width="700" height="180"></canvas>
</section>
<section class="card">
<h2>🔌 插座开关</h2>
<div class="ctrl-row">
<button id="btnSwitch" class="big-btn">--</button>
<div id="switchHint">等待设备状态</div>
</div>
</section>
<section class="card">
<h2>ℹ️ 设备信息</h2>
<div class="info-row">
<div>SN:<span id="iSn">-</span></div>
<div>Product:<span id="iProd">-</span></div>
<div>软件版本:<span id="iVer">-</span></div>
<div>软件编码:<span id="iCode">-</span></div>
</div>
<div class="edit-row">
<input id="newSn" placeholder="填写SN">
<input id="newProduct" placeholder="填写Product">
<button id="btnSetSn">设置SN&Product</button>
<button id="btnInfo">读取设备信息</button>
</div>
</section>
<section class="card">
<h2>⚙️ 计量校准</h2>
<div class="ctrl-row">
<button id="btnCalInit">校准初始化</button>
<button id="btnCali" disabled>执行校准</button>
</div>
</section>
<section class="card">
<h2>🆙 OTA升级</h2>
<div class="edit-row">
<input id="otaUrl" placeholder="固件http url">
<button id="btnOta">触发OTA升级</button>
</div>
</section>
<section class="card">
<h2>🧪 手动命令</h2>
<textarea id="cmdJson" rows="5" placeholder="输入JSON命令"></textarea>
<div class="ctrl-row">
<button id="btnSend">发送(Enter)</button>
</div>
<pre id="replies" class="reply-box"></pre>
</section>
<section class="card">
<h2>📜 运行日志</h2>
<div class="log-ctrl">
<label><input type="checkbox" id="chkLog">开启设备调试日志</label>
<label><input type="checkbox" id="chkScroll" checked>自动滚动</label>
<button id="btnLogClear">清空日志</button>
</div>
<div id="logbox" class="log-box"></div>
</section>
<div id="scanpanel" class="card hidden">
<h2>📡 发现设备</h2>
<div id="scandevs"></div>
<button id="btnScanAgain">重新扫描</button>
</div>
</main>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>
3.2 app.js
/* ESP32 智能插座 上位机 前端逻辑(原生 JS,无依赖) */
(function () {
"use strict";
// ================= 状态 =================
const S = {
seq: 0, // 已消费的日志序号
dataPollOn: true, // 是否自动轮询实时数据
lastSwitch: null, // 已知开关状态 0/1
lastData: null, // 最近一次 V/I/P
seenReplies: new Set(), // 已处理过的应答(去重,防重复提示/重复进面板)
powerBuf: [], // 功率曲线数据
};
const $ = (id) => document.getElementById(id);
// ================= 工具 =================
function toast(msg, kind) {
const el = $("toast");
el.textContent = msg;
el.className = "toast show" + (kind ? " " + kind : "");
clearTimeout(el._t);
el._t = setTimeout(() => { el.className = "toast"; }, 2600);
}
async function sendCmdWithNotify(cmdObj, cmdName) {
toast(`正在下发【${cmdName}】指令`,"");
addLog(`[SEND_CMD] ${cmdName} : ${JSON.stringify(cmdObj)}`);
const j = await sendCmd(cmdObj);
if(j && j.ok){
toast(`【${cmdName}】指令下发成功(桥接已接收)`, "ok");
addLog(`[SEND_OK] ${cmdName} 桥接下发完成,等待设备应答`);
}else{
toast(`【${cmdName}】指令下发失败`,"err");
addLog(`[SEND_FAIL] ${cmdName} 下发失败 `+(j?.error||""));
}
return j;
}
async function sendCmd(obj) {
try {
const r = await fetch("/api/cmd", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj),
});
const j = await r.json();
if (!j.ok) toast("发送失败: " + (j.error || r.status), "err");
return j;
} catch (e) {
toast("桥接服务未启动或已断开: " + e.message, "err");
return null;
}
}
// ================= 连接 =================
const ipEl = $("ip"), portEl = $("port");
function loadConn() {
ipEl.value = localStorage.getItem("plug.ip") || "192.168.1.100";
portEl.value = localStorage.getItem("plug.port") || "58888";
}
function saveConn() {
localStorage.setItem("plug.ip", ipEl.value.trim());
localStorage.setItem("plug.port", portEl.value.trim());
}
$("btnConnect").onclick = async () => {
const ip = ipEl.value.trim();
const port = parseInt(portEl.value.trim(), 10) || 58888;
if (!ip) { toast("请填写设备 IP", "err"); return; }
saveConn();
setConn("pending", "连接中...");
try {
const r = await fetch("/api/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip, port }),
});
const j = await r.json();
if (j.ok) toast("已切换目标: " + j.device, "ok");
} catch (e) {
setConn("off", "桥接服务未启动");
toast("无法连接桥接服务: " + e.message, "err");
}
};
function setConn(state, text) {
const dot = $("connDot");
dot.className = "dot " + state;
$("connText").textContent = text;
}
// ================= 设备发现(UDP 广播) =================
const scanPanel = $("scanpanel");
const scanDevs = $("scandevs");
let scanning = false;
async function doScan() {
if (scanning) return;
scanning = true;
scanDevs.innerHTML = '<div class="scan-empty">正在搜索设备,请稍候...(约 1.5 秒)</div>';
scanPanel.classList.remove("hidden");
const btn = $("btnScan");
btn.disabled = true;
btn.textContent = "搜索中...";
try {
const r = await fetch("/api/scan");
const j = await r.json();
if (!j.ok) throw new Error(j.error || ("HTTP " + r.status));
renderDevs(j.devices || []);
if (j.devices && j.devices.length) {
toast("发现 " + j.devices.length + " 台设备", "ok");
} else {
scanDevs.innerHTML =
'<div class="scan-empty">没有发现设备。请确认设备已联网,且与电脑在同一网段。</div>';
}
} catch (e) {
scanDevs.innerHTML =
'<div class="scan-empty">搜索失败:' + e.message + "(请确认桥接服务已启动)</div>";
} finally {
scanning = false;
btn.disabled = false;
btn.textContent = "📡 搜索设备";
}
}
function renderDevs(devs) {
scanDevs.innerHTML = "";
for (const d of devs) {
const row = document.createElement("div");
row.className = "dev";
const main = document.createElement("div");
main.className = "dev-main";
const sn = document.createElement("div");
sn.className = "dev-sn";
sn.textContent = d.sn || "(无 SN)";
const meta = document.createElement("div");
meta.className = "dev-meta";
meta.textContent =
"MAC " + (d.mac || "-") +
(d.mask ? " / " + d.mask : "") +
(d.dhcp === 1 ? " / DHCP" : "");
main.appendChild(sn);
main.appendChild(meta);
const ip = document.createElement("div");
ip.className = "dev-ip";
ip.textContent = d.ip || d.src || "-";
row.appendChild(main);
row.appendChild(ip);
row.title = "点击连接 " + (d.ip || "");
row.onclick = () => connectToFound(d);
scanDevs.appendChild(row);
}
}
function connectToFound(d) {
if (!d.ip) { toast("该设备没有有效 IP", "err"); return; }
ipEl.value = d.ip;
portEl.value = "58888";
saveConn();
scanPanel.classList.add("hidden");
$("btnConnect").click();
toast("已选择设备 " + d.sn + " (" + d.ip + "),正在连接...", "ok");
}
$("btnScan").onclick = doScan;
$("btnScanAgain").onclick = doScan;
// ================= 轮询 / 应答分发 =================
async function poll() {
let st = null;
try {
const r = await fetch("/api/poll?after=" + S.seq);
st = await r.json();
} catch (e) {
setConn("off", "桥接服务未启动");
return;
}
// 连接状态
if (st.connected) {
setConn("on", "已连接 " + st.device);
if (st.error) toast("设备: " + st.error, "err");
} else {
setConn("off", st.error || "未连接 " + st.device);
}
// 增量日志
if (st.logs && st.logs.length) {
for (const l of st.logs) addLog(l.text);
S.seq = st.next;
}
// 应答
if (st.replies) {
for (const rp of st.replies) handleReply(rp);
}
}
setInterval(poll, 2000);
function handleReply(rp) {
// 去重:同一应答只处理一次
if (S.seenReplies.has(rp.raw)) return;
S.seenReplies.add(rp.raw);
if (S.seenReplies.size > 30) {
const it = S.seenReplies.values().next().value;
S.seenReplies.delete(it);
}
addReply(rp.raw);
const obj = rp.obj, method = obj.method, payload = obj.payload || {};
const para = payload.para;
if (method === "info" && payload.type === "data" && para) {
S.lastData = para;
updateMeters(para);
} else if (method === "switch" && typeof payload.state !== "undefined") {
//保留原始switch分支逻辑,不做修改适配info返回开关状态
S.lastSwitch = payload.state;
updateSwitch(payload.state);
} else if (method === "info" && payload.type === "sys" && para) {
fillInfo(para);
} else if (method === "info" && payload.type === "sys") {
toast("SN 设置成功", "ok");
addLog("[DEVICE_REPLY] SN设置:设备应答成功");
} else if (method === "init" && obj.res === 1) {
toast("校准初始化完成,可以执行校准", "ok");
addLog("[DEVICE_REPLY] 校准初始化:设备应答成功");
$("btnCali").disabled = false;
} else if (method === "cali") {
toast("校准已触发,请等待设备完成", "ok");
addLog("[DEVICE_REPLY] 校准触发:设备应答");
} else if (method === "ota") {
toast("OTA 已触发,设备进入升级流程", "ok");
addLog("[DEVICE_REPLY] OTA触发:设备应答");
} else if (method === "energy" && obj.res === 1) {
toast("电能清除成功【设备应答】", "ok");
addLog("[DEVICE_REPLY] 清除电能:设备应答成功");
$("mep").textContent = "--.---";
} else if (method === "debug") {
// 静默
} else if (obj.res !== undefined) {
toast("设备应答: " + method + " res=" + obj.res, "ok");
addLog(`[DEVICE_REPLY] method:${method} res:${obj.res}`);
}
}
// ================= 实时数据 =================
function updateMeters(para) {
$("mv").textContent = (para.V !== undefined) ? para.V.toFixed(1) : "--.-";
$("mi").textContent = (para.I !== undefined) ? para.I.toFixed(3) : "--.--";
$("mp").textContent = (para.P !== undefined) ? para.P.toFixed(1) : "--.-";
$("mep").textContent = (para.EP !== undefined) ? para.EP.toFixed(3) : "--.---";
if (typeof para.P === "number") {
S.powerBuf.push(para.P);
if (S.powerBuf.length > 60) S.powerBuf.shift();
drawChart();
}
}
// 自动轮询实时数据
setInterval(() => {
if (!S.dataPollOn) return;
sendCmd({ method: "info", payload: { cmd: "get", type: "data" } });
}, 1000);
$("btnDataPoll").onclick = () => {
S.dataPollOn = !S.dataPollOn;
$("btnDataPoll").textContent = S.dataPollOn ? "暂停" : "恢复";
toast(S.dataPollOn ? "已恢复实时数据轮询" : "已暂停实时数据轮询");
};
// 功率曲线
const chart = $("chart");
const cctx = chart.getContext("2d");
function drawChart() {
const w = chart.width, h = chart.height;
cctx.clearRect(0, 0, w, h);
// 网格
cctx.strokeStyle = "#232e3e";
cctx.lineWidth = 1;
cctx.beginPath();
for (let g = 1; g < 4; g++) {
const y = Math.round(h * g / 4);
cctx.moveTo(0, y); cctx.lineTo(w, y);
}
cctx.stroke();
const pts = S.powerBuf;
if (pts.length < 2) return;
const maxV = Math.max.apply(null, pts);
const minV = Math.min.apply(null, pts);
const span = (maxV - minV) || 1;
const pad = 8;
cctx.strokeStyle = "#f5a623";
cctx.lineWidth = 2;
cctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const x = pad + (w - 2 * pad) * i / 59;
const y = h - pad - (pts[i] - minV) / span * (h - 2 * pad);
i === 0 ? cctx.moveTo(x, y) : cctx.lineTo(x, y);
}
cctx.stroke();
}
// ================= 开关 =================
function updateSwitch(state) {
const btn = $("btnSwitch");
const on = state === 1;
btn.textContent = on ? "开" : "关";
btn.classList.toggle("on", on);
$("switchHint").textContent = on ? "当前:开(已同步设备)" : "当前:关(已同步设备)";
}
$("btnSwitch").onclick = async () => {
const target = S.lastSwitch === 1 ? 0 : 1;
await sendCmdWithNotify({ method: "switch", payload: { cmd: "set", onoff: target } },"开关设置");
};
// 轮询开关状态
setInterval(() => {
sendCmd({ method: "switch", payload: { cmd: "get" } });
}, 2000);
// ================= 设备信息 =================
function fillInfo(para) {
$("iSn").textContent = para.sn || "-";
$("iProd").textContent = para.product || "-";
$("iCode").textContent = para.softcode || "-";
$("iVer").textContent = para.softversion || "-";
if (para.sn) $("newSn").placeholder = "当前 SN: " + para.sn;
}
$("btnInfo").onclick = async () =>
await sendCmdWithNotify({ method: "info", payload: { cmd: "get", type: "sys" } },"读取设备信息");
$("btnSetSn").onclick = async () => {
const sn = $("newSn").value.trim();
const prod = $("newProduct").value.trim();
if (!sn) { toast("请填写 SN", "err"); return; }
await sendCmdWithNotify({
method: "info",
payload: {
cmd: "set",
type: "sys",
para: { sn, product: prod }
}
},"设置SN&Product");
};
$("btnClearEnergy").onclick = async () => {
await sendCmdWithNotify({ method: "energy", payload: { cmd: "clear" } },"清除电能累计");
};
// ================= 校准=================
$("btnCalInit").onclick = async function () {
await sendCmdWithNotify({ method: "init", payload: { en: 1 } },"校准初始化");
};
$("btnCali").onclick = async () => {
if (!confirm("确认开始校准?\n设备需接已知负载(约 220V / 5A)才准确。")) return;
await sendCmdWithNotify({ method: "cali", payload: { en: 1 } },"执行校准");
};
// ================= OTA =================
$("btnOta").onclick = async () => {
const url = $("otaUrl").value.trim();
if (!url) { toast("请填写 OTA 固件 URL", "err"); return; }
if (!confirm("确认触发 OTA 升级?\n设备会立即下载并升级固件,期间会断开。" +
"\n\nURL: " + url)) return;
await sendCmdWithNotify({ method: "ota", payload: { url } },"OTA升级");
};
// ================= 日志 =================
const logbox = $("logbox");
$("chkLog").onchange = async (e) => {
await sendCmdWithNotify({ method: "debug", payload: { en: e.target.checked ? 1 : 0 } },"调试日志开关");
};
$("btnLogClear").onclick = () => { logbox.innerHTML = ""; };
function addLog(text) {
const line = document.createElement("div");
line.className = "log";
const tagMatch = text.match(/^\[([^\]]+)\]\s?(.*)$/);
if (tagMatch) {
const tag = document.createElement("span");
tag.className = "tag";
tag.textContent = "[" + tagMatch[1] + "]";
line.appendChild(tag);
line.appendChild(document.createTextNode(" " + tagMatch[2]));
const t = tagMatch[1];
if (/error|fail|fault|失败|错误/i.test(t + " " + tagMatch[2])) line.classList.add("error");
else if (/warn|告警|校验/i.test(t)) line.classList.add("warn");
} else {
line.textContent = text;
}
logbox.appendChild(line);
while (logbox.childElementCount > 100) logbox.removeChild(logbox.firstChild);
if ($("chkScroll").checked) {
logbox.scrollTop = logbox.scrollHeight;
}
}
// ================= 手动命令 =================
const repliesEl = $("replies");
$("btnSend").onclick = async () => {
const raw = $("cmdJson").value.trim();
if (!raw) { toast("请填写命令 JSON", "err"); return; }
let obj;
try { obj = JSON.parse(raw); }
catch (e) { toast("JSON 解析失败: " + e.message, "err"); return; }
toast("正在下发【手动命令】");
addLog(`[SEND_CMD] 手动命令: ${JSON.stringify(obj)}`);
const j = await sendCmd(obj);
if(j && j.ok){
toast("【手动命令】下发成功","ok");
addLog("[SEND_OK] 手动命令桥接下发完成");
}else{
toast("【手动命令】下发失败","err");
addLog("[SEND_FAIL] 手动命令下发失败");
}
};
$("cmdJson").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("btnSend").click(); }
});
function addReply(raw) {
let pretty = "";
try { pretty = JSON.stringify(JSON.parse(raw), null, 2); }
catch (e) { pretty = raw; }
const t = new Date().toLocaleTimeString();
repliesEl.textContent = "[ " + t + " ]\n" + pretty + "\n" + repliesEl.textContent;
const lines = repliesEl.textContent.split("\n");
if (lines.length > 50) repliesEl.textContent = lines.slice(0, 120).join("\n");
}
// ================= 启动 =================
loadConn();
drawChart();
})();
3.3 style.css
*{box-sizing:border-box;margin:0;padding:0;font-family:system-ui}
body{background:#121720;color:#e8edf3;padding:12px}
.topbar{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:16px}
.container{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
.card{background:#1c2430;padding:16px;border-radius:10px}
.hidden{display:none}
.meter-row{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px;margin:10px 0}
.meter-item{padding:8px;background:#273040;border-radius:6px}
.ctrl-row,.edit-row{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}
.info-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0}
button{padding:8px 14px;border:none;border-radius:6px;background:#2b5bcc;color:#fff;cursor:pointer}
button.secondary{background:#384458}
button.big-btn{font-size:20px;padding:14px 24px}
button.on{background:#209948}
input,textarea{padding:8px;background:#273040;border:1px solid #44526b;color:#fff;border-radius:6px}
.log-box{height:220px;overflow:auto;background:#0f151f;padding:8px;border-radius:6px}
.log{padding:2px 4px;font-size:13px}
.log.error{color:#ff7777}
.log.warn{color:#ffcc66}
.tag{color:#66b8ff}
.reply-box{min-height:140px;background:#0f151f;padding:8px;border-radius:6px;white-space:pre-wrap;font-size:12px}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:10px 18px;background:rgba(0,0,0,0.75);border-radius:8px;opacity:0;transition:0.2s;z-index:999}
.toast.show{opacity:1}
.toast.ok{background:#197c3c}
.toast.err{background:#a82c2c}
.conn-status{display:flex;align-items:center;gap:6px}
.dot{width:10px;height:10px;border-radius:50%}
.dot.off{background:#666}
.dot.on{background:#27c95f}
.dot.pending{background:#e6b020}
.scan-empty{padding:12px;color:#aaa}
.dev{padding:10px;background:#273040;border-radius:6px;margin:6px 0;cursor:pointer;display:flex;justify-content:space-between}
.dev:hover{background:#303d52}
.dev-sn{font-weight:bold}
.dev-meta{font-size:12px;color:#aaa}
.log-ctrl{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px}
canvas{width:100%;background:#0f151f;border-radius:6px}
四、调试注意事项
- 桥接成功≠设备应答成功
[SEND_OK]代表桥接收到指令;只有出现[DEVICE_REPLY],才代表 ESP32 设备返回执行结果。无设备应答时排查 TCP 链路、设备固件协议。 - 开关状态不更新排查 代码严格匹配
method === "switch";硬件switch get返回method=info属于固件报文不符合协议定义,需要修改 ESP32 固件应答字段。 - 内存保护机制 日志 DOM 节点上限、应答去重集合上限、功率数组长度限制,长时间页面运行不会内存泄漏。
- 本地存储 IP、端口保存在浏览器 localStorage,刷新页面自动回填。