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.h、WebServer.h 和 ESPmDNS.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 联系我们。

2对“ESP32 网页服务器:用浏览器直接控制继电器,不用 App 不用账号”的想法

  1. 自动引用通知: IoT 项目点子:10 个一学期做得完的 FYP 毕业专题

  2. 自动引用通知: ESP-01S WiFi 继电器模块:烧录与接线指南

Leave a Reply

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