ESP32 智能插座 Web 上位机设计与实现

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 智能插座设备

  1. 网页端:纯前端实现,通过fetch调用桥接服务暴露的 HTTP 接口;不直接和 ESP32 建立 TCP 连接,浏览器跨域与网络权限问题交由桥接层处理。
  2. 桥接服务:中间转发层,提供 HTTP API,内部完成 UDP 广播设备搜索、TCP 连接 ESP32、指令下发、设备报文接收、日志缓存。
  3. 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()发送指令,调试时无法区分:指令是否发给桥接、桥接是否转发成功、设备是否返回应答。 新增封装函数统一处理下发流程:

  1. UI 弹出 Toast 提示正在下发指令;
  2. 日志输出[SEND_CMD]记录下发指令名称与 JSON 报文;
  3. 调用底层sendCmd向桥接发送 HTTP 请求;
  4. 根据桥接返回结果打印[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)定时轮询桥接服务:

  1. 获取桥接连接状态,更新页面连接指示灯;
  2. 根据seq增量拉取设备运行日志,调用addLog渲染日志面板;
  3. 获取设备应答报文数组,循环调用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 实现曲线绘制,无图表库依赖。

  1. 清空画布,绘制横向网格辅助线;
  2. 获取功率数组最大、最小值做坐标归一化;
  3. 逐点计算 XY 坐标绘制折线;
  4. 数据不足两点不绘图。

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}

四、调试注意事项

  1. 桥接成功≠设备应答成功 [SEND_OK]代表桥接收到指令;只有出现[DEVICE_REPLY],才代表 ESP32 设备返回执行结果。无设备应答时排查 TCP 链路、设备固件协议。
  2. 开关状态不更新排查 代码严格匹配method === "switch";硬件switch get返回method=info属于固件报文不符合协议定义,需要修改 ESP32 固件应答字段。
  3. 内存保护机制 日志 DOM 节点上限、应答去重集合上限、功率数组长度限制,长时间页面运行不会内存泄漏。
  4. 本地存储 IP、端口保存在浏览器 localStorage,刷新页面自动回填。
相关推荐
华允物联-HUAIOT28 分钟前
基于网络IO的水泵远程控制系统方案
网络·物联网
淡淡的香烟4 小时前
Android中tcp的通信
android·物联网·tcp/ip
hnsoyon19 小时前
依托物联网工程守护城市地下管网运行安全
物联网·物联网工程·地下管网监测
qq_3164110320 小时前
物联网大健康管理平台的功能模块与技术架构详解
物联网
SL-staff1 天前
技术实践:解决IoT网关单点故障导致子设备批量‘失联’问题
物联网·zigbee·边缘缓存·jvs·iot架构·物模型设计·设备生命周期
TDengine (老段)1 天前
TDengine 应用案例 — 能源与电力监控
大数据·数据库·物联网·能源·时序数据库·tdengine·涛思数据
虎王物联1 天前
Docker WASM 边缘计算:12ms 冷启动如何重塑 IoT 网关部署
物联网·docker·容器·边缘计算·wasm
2601_956743681 天前
上海物联网应用开发技术选型指南:从HTTP/MQTT/Modbus协议适配、平台化架构设计到源代码交付与私有化部署,解析工程落地的关键考量点
物联网·网络协议·http·开发经验·上海
黎阳之光1 天前
软硬协同国产化 | 黎阳之光矩阵式视频融合管理系统完成摩尔线程国产GPU兼容适配
大数据·人工智能·物联网·安全·数字孪生