基于ESP32与Node.js的物联网远程控制系统:从HTTP轮询到家居自动化
1. 项目概述与核心价值如果你手头有一块ESP32开发板和几路继电器想实现一个能通过手机浏览器在办公室就能开关家里电灯、风扇甚至浇花系统的远程控制器那么这个项目就是为你量身定做的。它不仅仅是一个简单的“点灯”实验而是一个完整的、具备生产级雏形的物联网IoT远程控制系统原型。其核心在于我们不再依赖单一的、封闭的物联网平台而是自己搭建一个轻量级的Web服务作为“大脑”让ESP32作为“手脚”去执行指令并通过标准的HTTP协议进行通信。这种方式将控制权完全掌握在自己手中无论是数据隐私、功能定制还是后续扩展都拥有极高的自由度。这个方案的技术栈非常清晰ESP32负责硬件层面的连接与控制一个用Node.js或其他任何你熟悉的语言编写的后端服务负责逻辑与状态管理再加上一个简洁的前端网页作为控制面板。整个系统的工作原理可以类比为一个高效的快递系统网页控制端发货人发出“打开继电器1”的指令包裹Web服务快递中转站接收并记录这个新状态ESP32快递员每隔几秒就来中转站询问一次“有没有我的新指令”拿到指令后便立刻去执行送货上门。这种基于“轮询”的机制虽然不如WebSocket实时但胜在实现简单、稳定可靠非常适合对实时性要求不是极端苛刻的家居自动化场景。2. 系统架构设计与核心思路拆解2.1 为什么选择“Web服务轮询”架构在物联网远程控制方案中常见的架构有MQTT、WebSocket和HTTP轮询。我们选择HTTP轮询是基于以下几个务实的考量首先是极低的实现门槛。HTTP协议是互联网的基石几乎所有编程语言都有成熟、稳定的HTTP客户端和服务器库。这意味着你无需学习MQTT的订阅/发布模型或处理WebSocket的长连接维护上手速度极快。其次是出色的穿透性。HTTP/HTTPS的80/443端口在绝大多数网络环境包括公司、学校防火墙后都是开放的这使得你的控制端只要能上网就能访问服务省去了内网穿透等复杂配置。最后是状态管理的清晰性。Web服务天然就是一个中心化的状态管理器。继电器是开是关这个“真相”只存在于服务端。ESP32和多个网页客户端都向服务端同步或获取状态避免了多个客户端直接控制ESP32可能导致的指令冲突和状态混乱。当然轮询的缺点也很明显不是真正的实时且有额外的网络开销。但对于控制灯光、电器这类应用5秒甚至10秒的同步延迟是完全可接受的。而这点网络开销在家庭宽带环境下几乎可以忽略不计。这是一个在功能、复杂度、可靠性之间取得的完美平衡。2.2 硬件选型与电路设计考量项目的硬件核心是ESP32和4路继电器模块。选择ESP32而非ESP8266主要是看中其更强大的处理能力、更多的GPIO口以及蓝牙的备用通信通道可用于本地配置。继电器模块务必选择带光耦隔离的版本。光耦隔离意味着控制端ESP32的3.3V GPIO和被控端继电器驱动的220V市电在电气上是完全分离的仅通过光信号传递指令。这是至关重要的安全设计能有效防止高压浪涌窜入低压的微控制器烧毁你的核心芯片。关于继电器的接线需要理解两个关键概念常开NO和常闭NC。在继电器线圈未通电时公共端COM与常开端NO是断开的与常闭端NC是接通的。通电后状态翻转。在项目中我们通常将设备接在COM和NO之间。这样当ESP32输出高电平触发继电器时电路接通设备启动输出低电平时电路断开设备关闭。这种接法符合“低电平默认关闭”的安全直觉。如果你希望设备默认是开启的断电才关闭则可以接在COM和NC之间但这需要调整代码逻辑将继电器的常态设置为低电平触发。注意高压危险在进行220V市电部分接线时务必确保整个系统断电。使用绝缘良好的导线和接线端子裸露的金属部分必须用绝缘胶带包裹或置于配电盒中。如果你对强电操作不熟悉强烈建议先用低压直流设备如12V LED灯条进行所有功能和代码测试确认无误后再考虑接入市电。3. Web服务端系统的大脑与状态中心3.1 状态持久化方案选择Web服务首要任务是可靠地存储四个继电器的状态0或1。原文提到了几种方式环境变量、文件或NoSQL数据库。这里我们详细分析一下环境变量最简单但不持久。服务重启后状态就丢失了。仅适用于演示或临时测试。文本文件实现简单将状态写入一个JSON文件即可。但需要考虑多进程/多实例部署时的文件锁问题不适合高并发。Redis作为内存数据库读写速度极快非常适合这种小数据量的状态存储。并且它支持键过期等特性未来扩展功能如定时开关很方便。SQLite轻量级文件数据库无需单独部署数据库服务。对于这种单服务应用SQLite是一个可靠且简单的选择。对于个人项目或小规模应用我推荐使用SQLite。它在性能和简易性上取得了很好的平衡。下面是一个使用Node.jsExpress框架和SQLite的示例服务端核心代码。3.2 Node.js服务端核心代码实现首先初始化项目并安装依赖mkdir iot-relay-server cd iot-relay-server npm init -y npm install express sqlite3 cors创建server.js文件const express require(express); const sqlite3 require(sqlite3).verbose(); const cors require(cors); const app express(); const port 3000; // 使用中间件解析JSON和URL编码数据 app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cors()); // 允许前端跨域请求 // 连接SQLite数据库如果不存在会自动创建 const db new sqlite3.Database(./relay-state.db, (err) { if (err) { console.error(数据库连接失败:, err.message); } else { console.log(已连接到SQLite数据库。); // 创建状态表如果不存在 db.run(CREATE TABLE IF NOT EXISTS relay_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, relay1 INTEGER DEFAULT 0, relay2 INTEGER DEFAULT 0, relay3 INTEGER DEFAULT 0, relay4 INTEGER DEFAULT 0, esp32_ip TEXT, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ), (err) { if (err) { console.error(创建表失败:, err.message); } else { // 初始化一条记录ID为1 db.get(SELECT COUNT(*) as count FROM relay_state, (err, row) { if (row.count 0) { db.run(INSERT INTO relay_state (relay1, relay2, relay3, relay4) VALUES (0, 0, 0, 0)); } }); } }); } }); // 1. 获取所有继电器状态ESP32和前端都会调用此接口 app.get(/api/relay/status, (req, res) { db.get(SELECT relay1, relay2, relay3, relay4 FROM relay_state WHERE id 1, (err, row) { if (err) { res.status(500).json({ error: err.message }); return; } // 返回标准化的JSON响应 res.json({ relay1: row.relay1, relay2: row.relay2, relay3: row.relay3, relay4: row.relay4 }); }); }); // 2. 更新单个或多个继电器状态前端控制时调用 app.post(/api/relay/update, (req, res) { const { relay1, relay2, relay3, relay4 } req.body; // 构建动态更新的SQL语句只更新传入的参数 let updates []; let params []; if (relay1 ! undefined) { updates.push(relay1 ?); params.push(relay1); } if (relay2 ! undefined) { updates.push(relay2 ?); params.push(relay2); } if (relay3 ! undefined) { updates.push(relay3 ?); params.push(relay3); } if (relay4 ! undefined) { updates.push(relay4 ?); params.push(relay4); } params.push(1); // WHERE id 1 的参数 if (updates.length 0) { res.status(400).json({ error: 未提供任何更新参数 }); return; } const sql UPDATE relay_state SET ${updates.join(, )}, last_updated CURRENT_TIMESTAMP WHERE id ?; db.run(sql, params, function(err) { if (err) { res.status(500).json({ error: err.message }); return; } res.json({ message: 状态更新成功, changes: this.changes }); }); }); // 3. 接收ESP32上报的IP地址 app.post(/api/esp32/ip, (req, res) { const { ip } req.query; // 从查询参数获取IP if (!ip) { res.status(400).json({ error: IP地址不能为空 }); return; } db.run(UPDATE relay_state SET esp32_ip ?, last_updated CURRENT_TIMESTAMP WHERE id 1, [ip], function(err) { if (err) { res.status(500).json({ error: err.message }); return; } console.log(ESP32 IP已更新: ${ip}); res.json({ message: IP地址记录成功 }); }); }); // 启动服务器 app.listen(port, () { console.log(继电器状态服务运行在 http://localhost:${port}); });这个服务提供了三个核心API端点GET /api/relay/status: 供ESP32轮询和前端页面获取最新状态。POST /api/relay/update: 供前端页面在用户点击开关时更新状态。POST /api/esp32/ip: 供ESP32在启动时上报其本地IP地址可用于服务端日志或高级功能。使用node server.js启动服务后它就成为了整个系统唯一的状态权威。4. ESP32端可靠的硬件执行单元4.1 硬件连接与引脚定义将ESP32与4路继电器模块连接非常简单。继电器模块的控制端通常标有IN1, IN2, IN3, IN4分别对应四路继电器。它们需要连接到ESP32的GPIO引脚。连接步骤供电将继电器模块的VCC和GND分别连接到ESP32的3.3V和GND引脚。切记大部分这种模块的逻辑电压是3.3V或5V务必确认你的模块是3.3V驱动的否则可能损坏ESP32。控制信号将继电器模块的IN1至IN4分别连接到ESP32的任意四个数字输出引脚例如GPIO16,GPIO17,GPIO18,GPIO19。负载接线将你要控制的设备如灯的火线断开一端接继电器模块对应通道的COM公共端另一端接NO常开端。设备的零线直接接入市电零线保持不变。引脚定义示例// 定义控制继电器的GPIO引脚 const int relayPins[4] {16, 17, 18, 19}; // 对应继电器1到44.2 ESP32固件代码详解与配置以下是完整的Arduino IDE代码包含了详细的注释。你需要根据你的网络和服务器信息修改开头的配置部分。#include WiFi.h #include HTTPClient.h #include ArduinoJson.h // 用户配置区域 // WiFi配置可以配置两个网络ESP32会按顺序尝试连接 const char* ssid1 你的WiFi名称1; const char* password1 你的WiFi密码1; const char* ssid2 你的WiFi名称2; // 备用网络不需要可留空 const char* password2 你的WiFi密码2; // Web服务端点配置 const char* serverGetStatusURL http://你的服务器IP:3000/api/relay/status; const char* serverPostIPURL http://你的服务器IP:3000/api/esp32/ip; // 继电器控制引脚定义 const int relayPins[] {16, 17, 18, 19}; // 对应继电器1到4 const int relayCount 4; // 系统参数 const unsigned long statusUpdateInterval 5000; // 状态更新间隔毫秒5秒 unsigned long previousMillis 0; // 配置结束 void setup() { Serial.begin(115200); delay(1000); // 1. 初始化继电器引脚为输出模式并设置为初始状态低电平继电器断开 for (int i 0; i relayCount; i) { pinMode(relayPins[i], OUTPUT); digitalWrite(relayPins[i], LOW); // 低电平通常为断开状态具体看模块逻辑 Serial.printf(继电器 %d (引脚 %d) 已初始化。\n, i1, relayPins[i]); } // 2. 连接WiFi connectToWiFi(); // 3. 连接成功后向服务器上报本机IP地址 if (WiFi.status() WL_CONNECTED) { sendLocalIPToServer(); } } void loop() { // 检查WiFi连接如果断开则尝试重连 if (WiFi.status() ! WL_CONNECTED) { Serial.println(WiFi连接断开尝试重连...); connectToWiFi(); } // 定时任务每隔一定时间从服务器获取状态并更新继电器 unsigned long currentMillis millis(); if (currentMillis - previousMillis statusUpdateInterval) { previousMillis currentMillis; updateRelayStatusFromServer(); } // 可以在这里添加其他非阻塞任务 delay(100); // 防止loop空转消耗CPU } // 连接WiFi函数支持主备网络 void connectToWiFi() { Serial.println(正在连接WiFi...); WiFi.begin(ssid1, password1); int attempts 0; while (WiFi.status() ! WL_CONNECTED attempts 20) { // 尝试20次约10秒 delay(500); Serial.print(.); attempts; } if (WiFi.status() ! WL_CONNECTED ssid2[0] ! \0) { Serial.println(\n主网络连接失败尝试备用网络...); WiFi.begin(ssid2, password2); attempts 0; while (WiFi.status() ! WL_CONNECTED attempts 20) { delay(500); Serial.print(.); attempts; } } if (WiFi.status() WL_CONNECTED) { Serial.println(\nWiFi连接成功); Serial.print(本地IP地址: ); Serial.println(WiFi.localIP()); } else { Serial.println(\nWiFi连接失败请检查配置。); } } // 向服务器上报ESP32的IP地址 void sendLocalIPToServer() { if (WiFi.status() WL_CONNECTED) { HTTPClient http; String url String(serverPostIPURL) ?ip WiFi.localIP().toString(); http.begin(url); int httpCode http.POST(); // 发送空POST请求体 if (httpCode 0) { String payload http.getString(); Serial.printf(IP上报成功服务器响应: %d, %s\n, httpCode, payload.c_str()); } else { Serial.printf(IP上报失败错误代码: %d\n, httpCode); } http.end(); } } // 核心函数从服务器获取状态并更新继电器 void updateRelayStatusFromServer() { if (WiFi.status() ! WL_CONNECTED) { Serial.println(无法更新状态WiFi未连接); return; } HTTPClient http; http.begin(serverGetStatusURL); int httpCode http.GET(); if (httpCode HTTP_CODE_OK) { String payload http.getString(); Serial.println(收到服务器状态: payload); // 使用ArduinoJson库解析JSON DynamicJsonDocument doc(1024); DeserializationError error deserializeJson(doc, payload); if (!error) { // 遍历解析出的状态并更新对应的继电器引脚 for (int i 0; i relayCount; i) { String key relay String(i1); if (doc.containsKey(key)) { int relayState doc[key]; // 获取服务器下发的状态 (0或1) // 注意这里假设继电器模块是高电平触发。如果你的模块是低电平触发需要取反digitalWrite(relayPins[i], !relayState); digitalWrite(relayPins[i], relayState ? HIGH : LOW); Serial.printf(继电器 %d 设置为: %s\n, i1, relayState ? ON : OFF); } } } else { Serial.print(JSON解析失败: ); Serial.println(error.c_str()); } } else { Serial.printf(HTTP GET请求失败错误代码: %d\n, httpCode); } http.end(); }关键点解析与配置WiFi连接代码实现了主备网络连接提高了在移动设备或网络环境变化时的可靠性。HTTP通信使用HTTPClient库进行GET和POST请求。updateRelayStatusFromServer函数是核心它周期性地从服务器获取状态并更新GPIO输出。JSON解析使用ArduinoJson库来解析服务器返回的JSON数据。你需要在Arduino IDE的库管理中搜索并安装此库。触发逻辑代码中digitalWrite(relayPins[i], relayState ? HIGH : LOW);这行默认是服务器返回1HIGH时打开继电器。这一点至关重要有些继电器模块是高电平触发有些是低电平触发。你需要根据你的模块规格调整这行代码。如果不确定可以用一个LED做测试。轮询间隔statusUpdateInterval设置为5000毫秒5秒。你可以根据需求调整更短的间隔意味着更快的响应但会增加ESP32的功耗和服务器负载。实操心得继电器模块的触发逻辑拿到一个新的继电器模块第一件事就是用万用表或一个简单的LED电路测试其触发逻辑。写一个让GPIO循环输出高低电平的程序观察继电器在哪种电平下吸合。把这个逻辑关系记下来并在代码中正确实现。我曾因为搞反了逻辑导致设备在“关闭”指令下反而启动闹出过笑话。5. 前端控制面板简洁直观的人机界面前端页面是用户交互的入口。我们需要一个能显示当前状态、并能发送控制指令的网页。这里采用纯HTML/CSS/JavaScript实现无需任何框架部署极其简单。5.1 HTML结构与CSS样式创建一个index.html文件!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleESP32 四路继电器远程控制器/title style * { box-sizing: border-box; font-family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif; } body { background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; margin: 0; padding: 20px; } .container { background-color: white; padding: 30px 40px; border-radius: 20px; box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); max-width: 500px; width: 100%; } h1 { color: #2d3436; text-align: center; margin-bottom: 10px; } .subtitle { color: #636e72; text-align: center; margin-bottom: 30px; font-size: 0.9em; } .relay-card { background: #f8f9fa; border-radius: 15px; padding: 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; transition: all 0.3s ease; border-left: 5px solid #74b9ff; } .relay-card.on { border-left-color: #00b894; background: #e8f6f3; } .relay-card.off { border-left-color: #dfe6e9; } .relay-info h3 { margin: 0 0 5px 0; color: #2d3436; } .relay-info p { margin: 0; color: #636e72; font-size: 0.9em; } /* 滑动开关样式 */ .switch { position: relative; display: inline-block; width: 60px; height: 34px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } .slider:before { position: absolute; content: ; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } input:checked .slider { background-color: #00b894; } input:checked .slider:before { transform: translateX(26px); } .status-bar { margin-top: 25px; padding: 15px; background: #ffeaa7; border-radius: 10px; font-size: 0.9em; color: #d35400; text-align: center; } .status-bar.connected { background: #d1f7c4; color: #27ae60; } .buttons { display: flex; gap: 10px; margin-top: 20px; } button { flex: 1; padding: 12px; border: none; border-radius: 10px; font-weight: bold; cursor: pointer; transition: background 0.3s; } #btnAllOn { background-color: #00b894; color: white; } #btnAllOff { background-color: #e17055; color: white; } button:hover { opacity: 0.9; } /style /head body div classcontainer h1 智能家居继电器控制/h1 p classsubtitle实时状态同步 | 远程控制 | 状态span idconnectionStatus正在连接.../span/p div idrelayContainer !-- 继电器卡片将由JavaScript动态生成 -- /div div classbuttons button idbtnAllOn一键全开/button button idbtnAllOff一键全关/button /div div classstatus-bar idstatusBar 最后同步: span idlastSync从未/span /div /div script srcapp.js/script !-- 引入外部JS文件 -- /body /html5.2 JavaScript交互逻辑与API调用创建app.js文件包含所有控制逻辑// 配置你的Web服务地址 const SERVER_BASE_URL http://你的服务器IP:3000; const STATUS_API ${SERVER_BASE_URL}/api/relay/status; const UPDATE_API ${SERVER_BASE_URL}/api/relay/update; const SYNC_INTERVAL 5000; // 5秒同步一次 let relayStates [0, 0, 0, 0]; // 存储本地继电器状态 let lastSyncTime null; // 页面加载完成后初始化 document.addEventListener(DOMContentLoaded, function() { initRelayCards(); fetchRelayStatus(); // 首次加载时获取状态 setInterval(fetchRelayStatus, SYNC_INTERVAL); // 启动定时轮询 // 绑定一键操作按钮事件 document.getElementById(btnAllOn).addEventListener(click, () updateAllRelays(1)); document.getElementById(btnAllOff).addEventListener(click, () updateAllRelays(0)); }); // 初始化4个继电器卡片 function initRelayCards() { const container document.getElementById(relayContainer); const relayNames [客厅主灯, 卧室风扇, 书房插座, 阳台浇花]; for (let i 0; i 4; i) { const card document.createElement(div); card.className relay-card off; card.id relayCard${i}; card.innerHTML div classrelay-info h3${relayNames[i]}/h3 p继电器 ${i 1}/p /div label classswitch input typecheckbox idrelaySwitch${i} onchangetoggleRelay(${i}) span classslider/span /label ; container.appendChild(card); } } // 从服务器获取继电器状态 async function fetchRelayStatus() { try { const response await fetch(STATUS_API); if (!response.ok) throw new Error(HTTP错误! 状态码: ${response.status}); const data await response.json(); // 更新本地状态和UI for (let i 0; i 4; i) { const stateKey relay${i 1}; if (data.hasOwnProperty(stateKey)) { const newState data[stateKey]; if (relayStates[i] ! newState) { relayStates[i] newState; updateRelayUI(i, newState); } } } updateConnectionStatus(true); updateLastSyncTime(); } catch (error) { console.error(获取状态失败:, error); updateConnectionStatus(false); } } // 切换单个继电器状态 async function toggleRelay(relayIndex) { const newState relayStates[relayIndex] 1 ? 0 : 1; await updateRelayStateOnServer(relayIndex 1, newState); } // 更新单个继电器状态到服务器 async function updateRelayStateOnServer(relayNum, state) { const payload {}; payload[relay${relayNum}] state; try { const response await fetch(UPDATE_API, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(更新失败); const result await response.json(); console.log(继电器 ${relayNum} 更新为 ${state}:, result.message); // 注意这里不直接更新本地UI等待下一次轮询从服务器同步保证状态一致性 } catch (error) { console.error(更新继电器状态失败:, error); alert(控制指令发送失败请检查网络或服务器状态。); // 操作失败将开关状态回滚 document.getElementById(relaySwitch${relayNum-1}).checked !state; } } // 一键全开/全关 async function updateAllRelays(state) { const payload { relay1: state, relay2: state, relay3: state, relay4: state }; try { const response await fetch(UPDATE_API, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(批量更新失败); console.log(全部继电器更新为 ${state 1 ? ON : OFF}); // 同样等待轮询同步UI } catch (error) { console.error(批量操作失败:, error); alert(批量操作失败请稍后重试。); } } // 更新单个继电器的UI显示 function updateRelayUI(index, state) { const card document.getElementById(relayCard${index}); const checkbox document.getElementById(relaySwitch${index}); card.classList.remove(on, off); card.classList.add(state 1 ? on : off); checkbox.checked state 1; } // 更新连接状态显示 function updateConnectionStatus(isConnected) { const statusElem document.getElementById(connectionStatus); const statusBar document.getElementById(statusBar); if (isConnected) { statusElem.textContent 已连接; statusElem.style.color #27ae60; statusBar.className status-bar connected; statusBar.textContent 服务器连接正常; } else { statusElem.textContent 连接中断; statusElem.style.color #e74c3c; statusBar.className status-bar; statusBar.innerHTML ⚠️ 无法连接到服务器请检查网络。; } } // 更新最后同步时间 function updateLastSyncTime() { const now new Date(); lastSyncTime now; const timeStr now.toLocaleTimeString(zh-CN, { hour12: false }); document.getElementById(lastSync).textContent timeStr; }前端设计要点状态同步前端不“认为”自己的操作一定成功。它发送控制指令后不立即改变本地开关状态而是等待下一次定时轮询从服务器获取“权威状态”来更新UI。这保证了即使多个浏览器同时操作大家看到的状态始终是一致的。用户体验通过CSS实现了美观的滑动开关和状态卡片。连接状态和最后同步时间实时显示让用户对系统健康状况一目了然。错误处理网络请求都有try...catch包裹失败时会给出用户提示并将开关状态回滚防止UI与真实状态不同步。将index.html和app.js放在任何一个静态网页服务器如Nginx, Apache下或者直接用VS Code的Live Server插件打开就能通过浏览器访问这个控制面板了。6. 系统部署、调试与问题排查实录6.1 完整部署流程部署Web服务将server.js和package.json上传到你的云服务器如腾讯云、阿里云ECS或本地能长期开机的电脑如树莓派。运行npm install安装依赖然后用node server.js启动服务。对于生产环境建议使用pm2进程管理器来守护进程pm2 start server.js --name relay-server。配置ESP32在Arduino IDE中安装ESP32开发板支持。将上面的ESP32代码中的WiFi名称、密码以及serverGetStatusURL和serverPostIPURL修改为你实际的服务器IP地址和端口。编译并上传代码到ESP32。部署前端页面将index.html和app.js中的SERVER_BASE_URL同样修改为你的服务器地址。你可以将它们放在和Node服务同一台机器的另一个端口例如用Nginx代理或者为了方便直接放在一个静态托管服务如GitHub Pages上。这样你就可以在任何地方通过浏览器访问控制页面了。硬件连接与上电按照接线图连接好ESP32、继电器模块和负载。先给ESP32上电观察串口监视器输出确认其成功连接WiFi并上报了IP。然后给继电器模块的负载端上电。6.2 常见问题与排查技巧在实际搭建过程中你几乎一定会遇到下面这些问题。这里是我踩过坑后总结的排查清单问题现象可能原因排查步骤与解决方案ESP32无法连接WiFi1. SSID/密码错误。2. WiFi信号太弱。3. 路由器设置了MAC过滤或仅允许特定设备。1. 检查代码中的SSID和密码注意大小写和特殊字符。2. 将ESP32靠近路由器测试。3. 查看串口输出确认错误代码。尝试用手机热点测试以排除路由器配置问题。ESP32串口显示连接成功但无法访问服务器1. 服务器IP/端口错误。2. 服务器防火墙未开放端口。3. ESP32与服务器不在同一网络如服务器在公网ESP32在内网且无端口映射。1. 在电脑上用浏览器访问http://服务器IP:3000/api/relay/status看是否能返回JSON数据。2. 检查云服务器的安全组规则或本地电脑的防火墙确保3000端口入站规则已开放。3. 若服务器在公网ESP32在内网需在路由器上设置端口转发或将服务部署在内网。网页能打开但开关无反应状态不同步1. 前端JS中服务器地址配置错误。2. 浏览器跨域问题CORS。3. 服务器Node服务未运行或崩溃。1. 按F12打开浏览器开发者工具查看“网络(Network)”标签页检查对/api/relay/status的请求是否成功状态码200。2. 确认Node服务代码中已使用cors()中间件。3. 检查服务器后台确保Node进程正在运行。查看是否有错误日志。继电器状态混乱或触发逻辑相反1. 继电器模块是高/低电平触发逻辑与代码设置不符。2. GPIO引脚定义错误。3. 继电器模块供电不足。1.这是最常见的问题单独测试继电器写一个简单程序循环设置某个GPIO为HIGH 2秒LOW 2秒用万用表测量信号脚与GND之间电压或听继电器吸合声确认触发逻辑。2. 核对代码中relayPins数组与实际接线是否一致。3. 确保继电器模块的VCC接的是稳定的3.3V电源必要时可外接电源但需共地。控制有延迟或偶尔失效1. 网络延迟或丢包。2. ESP32轮询间隔设置太短服务器压力大或被限流。3. WiFi信号不稳定。1. 增加轮询间隔如改为10秒。2. 在服务器端和ESP32端加入更完善的错误重试机制。3. 优化ESP32的天线位置或考虑使用外部天线。多个网页同时控制时状态不同步前端逻辑问题可能在本地直接切换了开关状态。确保遵循我们前端代码的设计前端只发送指令不预设结果UI状态永远从服务器轮询获取。这样任何客户端的操作都会经服务器同步给所有客户端和ESP32。实操心得调试是必修课物联网项目三分靠搭建七分靠调试。务必善用工具ESP32的串口监视器是你的第一双眼睛所有网络连接、HTTP请求响应都应打印出来。浏览器的开发者工具F12是第二双眼睛查看网络请求和Console错误。服务器日志是第三双眼睛。从这三处输出的信息90%的问题都能定位。6.3 安全与优化建议基础安全目前的HTTP是明文的状态API也是公开的。在生产环境至少要做以下几点使用HTTPS为你的Node.js服务配置SSL证书可以用Let‘s Encrypt免费获取并将前端和ESP32的请求地址改为https。增加简单认证在API请求头中加入一个固定的Token进行验证防止他人随意调用你的接口。修改默认端口不要使用3000这类常见端口。功能优化状态反馈ESP32可以在执行继电器操作后将实际读取的GPIO状态再上报给服务器实现双向确认。定时任务在服务器端增加定时任务可以在特定时间自动改变继电器状态实现定时开关。历史记录在数据库中添加一张表记录每次状态变化的时间、来源网页/定时便于查询审计。OTA升级为ESP32实现空中升级功能以后修复bug或增加功能无需再手动插线烧录。这个项目提供了一个坚实且高度可扩展的基石。从这里出发你可以轻松地将4路继电器扩展到更多路可以接入温湿度传感器实现自动控制可以集成语音助手甚至可以搭建一个完整的家庭自动化仪表盘。