ESP32 网页服务器:用浏览器直接控制继电器,不用 App 不用账号

Cartoon of a laptop showing a large teal toggle switch, sending signal arcs through a white WiFi router to a red relay module with a blue relay cube that runs a cream desk fan, beside a crossed-out grey cloud

可以 —— 控制页面就跑在 ESP32 自己身上:烧录下面这份代码,在同一个 WiFi 下用任何浏览器打开 http://esp32.local/ 或板子的 IP 地址,页面上两个按钮就能切换一块只用三条杜邦线接好的 5 V 继电器模块。手机上什么都不用装,不用注册账号,也不需要互联网 —— 这个请求根本不会离开你家路由器。

用浏览器控制继电器,需要哪些零件?

ESP32 DevKit 在这个方案里就是服务器本身,而整条链路都留在你家里:浏览器向它要一个页面,板子透过你的 WiFi 把页面答回去,然后它的一个引脚去驱动继电器模块,由模块的触点去开关真正的负载 —— 一把 12 V 风扇、一条灯带或者一个水泵。

选配 —— 一个继电器变成三个时的扩展底板

ItemPriceQty
NodeMCU ESP32 Wi-Fi + Bluetooth Development Board CH340/CP2012 - For IOT Project - ESP32 30PIN EXPANSION BOARDNodeMCU ESP32 Wi-Fi + Bluetooth Development Board CH340/CP2012 - For IOT Project - ESP32 30PIN EXPANSION BOARD32DEVBD-X32ECH34RM8.90

选配的 30 针扩展底板,正好坐得下这一款 DevKit:一个可接 6.5–16 V 输入的 DC 圆头插座,每个引脚旁边都引出了各自的 5 V 和 GND 排针。等一个继电器变成三个的时候,它就是让线路整齐起来的那块底板。

那块扩展板引出来的是公针,所以要配母对母杜邦线。另外有两样很接近的零件是特意没有放进清单的:紫色的 ESP32-C2 是一块走 ESP-IDF 的板子,标准 Arduino 开发板管理器并不收录它;而同一款红色继电器板还有12 V 线圈的版本 —— 只有当你的系统本来就有一路 12 V 电源时,才去买那一款。

一个网页服务器怎么塞得进 ESP32 里?

ESP32 core 自带的 WebServer 库,说穿了就是一个 socket 加一张查找表。WebServer server(80) 告诉芯片去监听 TCP 的 80 端口,也就是浏览器打开普通 http:// 地址时用的那个端口。你在浏览器里打开 http://192.168.1.50/on,浏览器送出去的其实只是一行文字:GET /on HTTP/1.1

每写一句 server.on("/on", handleOn),就是往那张表里加一行 —— 路径 /on 对应到 C 函数 handleOn。这一对配对关系就叫一条路由。在 loop() 里被调用的 server.handleClient() 会看看有没有请求在等,读出它的路径,找到对应的那一行并调用那个函数;函数再用 server.send() 把文字沿同一条连接写回去,浏览器就把页面画出来。

整条路上再没有别的东西参与,所以这里根本没有账号要注册。数据包只走 浏览器 → 路由器 → ESP32 再原路回来,所以就算你家宽带断了,这个开关照样能用 —— 只要 WiFi 还活着就行。代价也在同一句话里:它只在你自己的网络里有效,而我们那篇 Blynk 手机控制教程正是拿这一点去换一次云端中转,好让你用手机流量在外面也能控制。这同时也意味着,你的 WiFi 密码就是这个页面唯一的一道锁:任何已经连上这个网络的人都能打开它,所以请把板子放在你自己掌控的网络里,而不是那个到处给人用的网络。

卡通示意图:笔记本电脑上显示着青色开关,一支青色箭头穿过白色 WiFi 路由器射向一块黑色 ESP32 板,板子再把应答传回去,三条杜邦线从板子接到一块红色继电器模块
请求的回路就停在板子这里。浏览器发问,ESP32 自己把页面答回去,然后才由一个引脚去带动继电器 – 这条路上没有别处的服务器。

想先看看一块板子吐出它第一个页面的样子?

继电器模块要怎么接到 ESP32 上?

1 路继电器模块的控制端是一个标着 IN、DC− 和 DC+ 的三位螺丝端子,不是排针 —— 所以清单里配的是公对母杜邦线:母头那端插在 DevKit 的引脚上,裸露的公头那端压进螺丝底下拧紧。三条线,不用面包板,也不用焊。

继电器端子 DevKit 引脚 为什么
DC+ VIN 线圈是一块 5 V 电磁铁;VIN 把 USB 口的 5 V 直接引过来
DC− GND 线圈电流和控制信号共用的回流路径
IN D26 GPIO26 —— 一个普通的完整输出脚,开机全程都很安静

线圈要从 VIN 供电,不要接 3V3 引脚,数字本身就说明了原因。SRD-05VDC 的线圈电阻约 70 Ω,5 V 大约推 71 mA 过去 —— 这点电流对 USB 供电来说不算什么 —— 但松乐规定的吸合电压是额定线圈电压的 75%,也就是约 3.75 V,所以 3.3 V 很可能永远拉不动那片衔铁。供电这一侧,我们那篇 ESP32 供电指南讲得更细。

把模块上那颗黑色的触发跳线帽拨到 H,也就是高电平那一挡,RELAY_ON 就是 HIGH。这颗跳线在板子下缘、紧挨着 IN/DC−/DC+ 端子的那排三针上:中间一针是公共针,两侧的针丝印分别是左边的 L 和右边的 H,所以黑色跳线帽要把中间针和右边那一针短接起来。在 H 挡,光耦里的 LED 是由 IN 经过模块自带的 1 kΩ 电阻流向 DC− 的,所以一个 3.3 V 高电平大约推 2 mA 过去 —— 这点负载 ESP32 的引脚带起来毫不吃力 —— 而 0 V 就把它彻底截止。L 挡才是 3.3 V 板子的陷阱:在那一挡,同一颗 LED 改挂在模块的 DC+ 电源轨上,要等 IN 爬到距离那条轨大约 1.5 V 以内才会截止,在 5 V 模块上大概是 3.5 V(这款模块的实测电路图)。3.3 V 的高电平永远够不到这条线,所以跳线坐在 L 挡时,继电器从模块一上电就吸合,而且再也不会释放。

选 GPIO26 也是同一个道理。复位时 ESP32 会去读它的 strapping 引脚 —— GPIO0、2、5、12 和 15 —— 来决定怎么启动,而在它做决定的那段时间里这些脚的电平会晃动,于是继电器每次重启都要「咔」一声。GPIO34、35、36 和 39 则是另一种坑:它们是只能输入的引脚,没有输出驱动器,所以什么都开关不了。GPIO26 这两个毛病都没有;而且它在上电之初还是一个未配置的输入脚时,也不会往 H 挡那条链路里灌出任何电流,所以继电器一开始就是断开的。

负载接在板子的另一头:从它自己的电源引正极进 COM,从 NO(常开)出来接到负载,负载的负极再直接回到同一个电源 —— 在继电器吸合之前,这条回路是断的。改接到 NC 就反过来,负载一直在跑,直到继电器动作才断开。这三个端子是密封在那颗蓝色方块里的机械触点,和线圈、和 ESP32 都是隔离的,所以负载的电源不需要跟板子共地。请只用低压直流:蓝色方块上印着的触点额定值确实有市电级别,但要开关市电,就得有外壳、爬电距离和线缆固定,这些是一块裸露在桌面上的板子给不了的。这一侧的问题,我们那篇继电器安全指南讲得比较全。

卡通特写:一块红色继电器模块,上面是蓝色继电器方块,三条杜邦线压在蓝色螺丝端子里,黑色的触发跳线帽被一个青色圆圈标了出来
控制端是螺丝端子,不是排针 —— 杜邦线裸露的公头压在螺丝底下拧紧,黑色触发跳线帽则坐在高电平那一对针上。

ESP32 网页服务器的代码长什么样?

这份 ESP32 代码只用到 ESP32 core 本身就带的库 —— WiFi.hWebServer.hESPmDNS.h。而这个 core,正是一个全新的 Arduino IDE 唯一还缺的东西:把乐鑫的开发板包网址贴进「偏好设置 → 附加开发板管理器网址」,再到开发板管理器里安装 esp32 by Espressif Systems。没有它,就没有 ESP32 Dev Module 这一项,也没有 WebServer.h。选好那块板,把你家 2.4 GHz 网络的名称和密码填进去,上传即可。有一个设计上的取舍值得照抄:/on/off 自己并不画页面。它们只改 relayState,然后回一个 HTTP 303 重定向把浏览器送回 /,由 / 依据那个变量重新生成页面 —— 于是页面报出来的永远是继电器真实的状态,而刷新一下只是重新请求 /,不会把上一条命令再执行一遍。

// ESP32 网页服务器继电器 - 在自己家的 WiFi 下用任何浏览器切换继电器。
// 不用 App,不用云端账号,也不需要互联网。开发板:ESP32 Dev Module。

#include <WiFi.h>
#include <WebServer.h>   // ESP32 core 自带 - 不用另外安装
#include <ESPmDNS.h>     // 让板子能应答 esp32.local 这个名字

const char WIFI_SSID[] = "YourWiFiName";       // 必须是 2.4 GHz 网络
const char WIFI_PASS[] = "YourWiFiPassword";
const char HOSTNAME[]  = "esp32";              // 会变成 http://esp32.local/

const int RELAY_PIN = 26;      // 丝印 D26 - 不是 strapping 引脚
const int RELAY_ON  = HIGH;    // 触发跳线在 H 挡:把 IN 拉高 = 吸合
const int RELAY_OFF = LOW;

bool relayState = false;       // 唯一的真实状态,保存在板子上
WebServer server(80);          // HTTP 监听 80 端口

void applyRelay() {
  digitalWrite(RELAY_PIN, relayState ? RELAY_ON : RELAY_OFF);
}

// 每次有人来要页面,页面都是依据 relayState 现场生成的,
// 所以刷新出来的永远是继电器真实的状态。
String buildPage() {
  String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"
                "<meta name='viewport' content='width=device-width,initial-scale=1'>"
                "<title>ESP32 Relay</title><style>"
                "body{font-family:sans-serif;text-align:center;margin-top:12vh}"
                "a{display:inline-block;padding:18px 40px;margin:8px;border-radius:12px;"
                "color:#fff;font-size:20px;text-decoration:none}"
                ".on{background:#0a7d5a}.off{background:#9b2226}</style></head><body>";
  html += "<h1>Relay is ";
  html += relayState ? "ON" : "OFF";
  html += "</h1><p><a class='on' href='/on'>Turn ON</a>"
          "<a class='off' href='/off'>Turn OFF</a></p></body></html>";
  return html;
}

// 动作做完后,把浏览器送回 "/",这样刷新页面
// 不会再把同一条命令执行一遍。
void redirectHome() {
  server.sendHeader("Location", "/");
  server.send(303, "text/plain", "");
}

void handleRoot() { server.send(200, "text/html", buildPage()); }
void handleOn()   { relayState = true;  applyRelay(); redirectHome(); }
void handleOff()  { relayState = false; applyRelay(); redirectHome(); }

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  applyRelay();                       // 别的代码跑起来之前,先让继电器断开
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  WiFi.setHostname(HOSTNAME);         // 必须在 begin() 之前设置
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Open http://");
  Serial.print(WiFi.localIP());       // 把这个地址记下来
  Serial.println("/ in any browser on this WiFi");

  if (MDNS.begin(HOSTNAME)) {         // WiFi 连上之后才有效
    MDNS.addService("http", "tcp", 80);
    Serial.println("Also try http://esp32.local/");
  }

  // 每个 server.on() 把一条 URL 路径映射到一个 C 函数 - 这就是一条「路由」
  server.on("/",    handleRoot);
  server.on("/on",  handleOn);
  server.on("/off", handleOff);
  server.onNotFound([]() { server.send(404, "text/plain", "No such page"); });
  server.begin();
}

void loop() {
  server.handleClient();   // 看看有没有请求在等,有就答复它
}

这份代码编译出来是 956,512 字节 —— 占默认 4 MB 分区方案里那块 1,310,720 字节应用分区的 72%,所以完全不必重新分区。上传完把串口监视器切到 115200 波特率:板子会把地址打印出来,你在同一个 WiFi 下的任何浏览器里输入它就行。

要怎么找到这块板 —— esp32.local 还是固定 IP?

ESPmDNS 这个库能在整个网络里没有任何 DNS 服务器的情况下,给板子一个名字。MDNS.begin("esp32") 会加入 224.0.0.251 这个组播组,并监听 UDP 的 5353 端口;当局域网里有设备大声问一句「谁是 esp32.local?」,板子就把自己的地址答回去。一喊一应而已 —— 这也正是它永远不会越过你家路由器的原因。

问题在于发问的那台设备必须会讲 mDNS。macOS、iOS、Windows 10 及以上,还有大多数 Linux 桌面都会。Android 要到 Android 12 才在浏览器里支持解析 .local,而且就算支持也时灵时不灵 —— 在不少手机上,这个地址就是一直超时。访客网络,以及开了客户端隔离的路由器,则会直接把组播整个挡掉。

所以把 esp32.local 当成方便,把固定地址当成可靠。因为这份代码在连线之前先调用了 WiFi.setHostname("esp32"),板子会以这个名字出现在路由器的已连接设备列表里;把它旁边的 MAC 地址记下来,再加一条 DHCP 保留,把那个 MAC 绑到一个你自己挑的地址上。之后路由器每次开机都会把同一个地址发给这块板 —— 这比在代码里写死一个静态 IP 稳妥,写死的那个有可能和路由器自己的地址池撞车。

每一款路由器的管理页面,摆放的位置都不一样:

WebServer.h 还是 ESPAsyncWebServer,该用哪一个?

ESPAsyncWebServer 是大多数 ESP32 网页服务器示例首选的库,可是对一个开关灯这种活儿,它换来的只有两样额外要装的东西:服务器本体,加上它底下的 AsyncTCP,而且两者之间、以及它们和你的 core 之间,版本都要对得上。WebServer.h 则是跟着 ESP32 core 一起来的。

WebServer.h(core 自带) ESPAsyncWebServer
需要额外安装 不用 两个库
由谁驱动 loop() 里的 handleClient() TCP 协议栈的回调
同时服务几个客户端 一个 好几个
loop() 变慢时 每个请求都被拖慢 不影响请求的处理
WebSocket、文件上传 很别扭 内建支持

差别在于「等待」这件事归谁管。handleClient() 是在你的 loop() 里把一整个请求处理完的,所以你自己的代码一忙起来,就没人应答 —— 不过在没有请求排队时它几乎立刻就返回,这也是两个链接加一个继电器怎么用都不觉得慢的原因。ESPAsyncWebServer 跑在它自己的 FreeRTOS 任务上,由 TCP 协议栈在数据包到达时把它唤醒,所以你的 loop() 根本不在这条路径上。上面那一行「不影响请求的处理」,说的就是这件事。等到你要用 WebSocket 推实时传感器数值、要从 LittleFS 里读文件,或者预期全家的手机同时来访,再升级过去也不迟。

我们从真实客户那里看到的常见错误

想去连 5 GHz 网络。ESP32 的射频只有 2.4 GHz,所以一个只发 5 GHz 的 SSID 在它眼里根本不存在,串口监视器就会一直打点下去。把两个频段用同一个名字广播的路由器通常没问题;万一不行,就把两个频段拆成两个名字,把 2.4 GHz 那个给板子。

用手机流量或访客网络去开这个页面。这个页面没有公网地址 —— 它只存在于你的局域网里 —— 所以一台掉回 4G 的手机,或者连上了访客 SSID 的手机,超时的样子和板子死了一模一样。

loop() 里放 delay()请求只有在 handleClient() 跑到的时候才会被应答,所以旁边一句 delay(2000) 就能让页面最多要两秒才有反应。慢的活儿请用 millis() 来计时。

用只能充电的 USB 线上传。板子会通电、看起来一切正常,可是串口就是不出现 —— 那种线四根线里少了两根。材料清单里那条 USB-C 数据线就是为这件事准备的。如果端口出来了、上传却卡在 Connecting........,按住板子上的 BOOT 键不放,直到那排点开始往前走;要是端口还是不肯出现,我们那篇 ESP32 无法识别或上传失败指南把整条链路都走了一遍。

常见问题

为什么 esp32.local 在我的笔记本上能用,在 Android 手机上却不行?

因为解析这个名字的是手机,不是板子。Android 要到 Android 12 才在浏览器里加入对 .local 的查找,而且支持得并不齐整。在那台手机上直接用板子的 IP 地址,再到路由器里加一条 DHCP 保留,把这个地址固定下来。

人不在家的时候,我能切换这个继电器吗?

用这份代码不能,而且这是刻意的。也不要为它做端口转发 —— 页面上没有任何登录,谁找到了地址谁就能操作你的继电器。要从外面控制,请改用带账号保护的云端链路,例如我们那篇 Blynk 手机控制方案

为什么 ESP32 一重启,我的继电器就自己响一声?

IN 那条线接在了 strapping 引脚上 —— GPIO0、2、5、12 或 15 —— 芯片每次复位、在判断该怎么启动的那一刻都会去动它们。把它挪到一个安静的完整输出脚 —— GPIO26、27、25 或 33。

我可以改用 12 V 的继电器模块吗?

如果只是要开关一个 12 V 的负载,通常并不需要。线圈电压和负载电压是两回事 —— 5 V 模块的触点印着 10 A 30 V DC,所以线圈跑在 USB 上的同时,它照样能开关一把 12 V 风扇。12 V 那个版本是给整套系统只有一路 12 V 电源的场合用的:DC+ 接那路电源,DC− 接一条和 ESP32 共用的地,IN 照样接 D26,跳线也照样放在 H —— 在 H 挡,光耦是在高出 DC− 约 1.5 V 的地方翻转的,跟线圈电压无关,所以 3.3 V 逻辑驱动起来完全一样。把 12 V 模块的跳线拨到 L,继电器就再也不会释放,因为 IN 得爬到距离 12 V 约 1.5 V 以内才停得住 —— 而那时模块以 12 V 为基准的输入端就顶在一个 3.3 V 的引脚上,那不是一个该让 GPIO 待着的状态。至于用 DevKit 的 5 V VIN 脚去喂一颗 12 V 线圈,那是根本吸合不了的。

同一个页面上可以再加几个继电器吗?

可以。给每个继电器各自一个引脚和一个状态变量,每个继电器加一对 server.on() 路由,再依据全部状态变量去生成页面。每颗 5 V 线圈大约多吃 71 mA,所以超过两三个之后,就让这些模块吃它们自己的 5 V 电源,再把两边的地接在一起。

卡通桌面实作图:笔记本电脑上开着青色的开关页面,旁边是一块小小的黑色 ESP32 板,三条杜邦线接到一块红色继电器模块,模块再带动一把米白色桌面风扇
完工的桌面:USB 供电进来,三条控制线接到继电器,低压负载接在螺丝端子上,控制页面就开在同一个 WiFi 下的笔记本电脑上。

一旦板子能自己吐出页面,剩下的无非就是再加路由:按钮旁边多一个传感器读数、多接一个继电器、加一段到了傍晚自动切换的时间表。想改成用说话来控制?我们那篇离线语音控制继电器,不靠任何网络就能带动同一款模块。

最后更新:2026 年 8 月。有问题?WhatsApp 联系我们

Leave a Reply

Your email address will not be published. Required fields are marked *