1. 项目概述:用ESP32把WiFi和BLE捏合到一套智能家居里
家里设备多起来之后,我最大的痛点不是“缺一个遥控器”,而是为了控制不同东西装了五六个App:灯的App、插座App、加湿器App、体脂秤App,界面各不相同,数据还不互通。后来我用ESP32自己搭了一套方案,把WiFi设备和BLE设备统一接入,自己控制逻辑自己写,数据也全部留在本地。核心思路很简单:ESP32做中枢,WiFi跑远程管理和数据上报,BLE负责跟低功耗小设备通信,两套协议在同一个芯片里协同工作。这套方案我用了大半年,稳当、可控、成本也低,所以整理出来分享给同样想折腾智能家居的人。
这块内容适合谁?如果你对嵌入式开发有一定基础,比如用过Arduino或者写过一点C语言,跟着走完全没问题;就算你是纯小白,只要愿意照着一行行抄代码、接线,也能把基础版本跑起来。做完之后你会拥有一个能远程看温湿度、控制继电器开关、用手机蓝牙离线操控的本地智能家居节点,而且后续扩展能力很强,加传感器、加电机、接入Home Assistant都留好了口子。
2. 整体设计思路:为什么WiFi和BLE必须两个都要?
2.1 WiFi管“远”,BLE管“省”,两者是互补关系
很多新手会问:ESP32既然都支持WiFi和BLE,那直接用WiFi连所有设备不就行了吗?这个问题我一开始也想过,但实际做了一圈就明白:不是所有设备都适合走WiFi。你看智能家居里最常见的传感器,比如温湿度、门磁、人体红外、纽扣电池供电的蓝牙标签,这些设备的工作特点是数据量小、不频繁、要求功耗极低。BLE的广播和GATT连接在低功耗模式下能做到极低的平均电流,一颗CR2032纽扣电池撑几个月甚至一年很正常。而WiFi本身是为高数据吞吐设计的,模块休眠和连接建立的开销很大,电池小的设备根本扛不住。
反过来,WiFi的优势在于传输距离、带宽和上层生态。家里隔一堵墙、两堵墙,WiFi信号通常比BLE稳得多,而且可以轻松把手上的数据推到手机、云端或者局域网内的服务端,支持HTTP、MQTT、WebSocket这些现成协议。所以我最终定下来的架构是一台ESP32当“网关”,WiFi负责连接家里的路由器、跟服务器的通信;BLE负责跟各种低功耗终端打交道。这样既保住了WiFi设备的远程可控性,也保留了蓝牙设备的低成本长续航特性。
2.2 整体架构和数据流向
整套方案分成三层:
- 设备层:温湿度传感器、继电器、红外传感器、蓝牙小模块等,负责采集物理世界的数据或执行动作。
- 网关层:ESP32位于中间,一边通过GPIO/ADC/I2C直接连接简单传感器,一边通过BLE连接无线传感器,再统一处理这些数据。
- 应用层:手机App、Web浏览器、MQTT服务器、Home Assistant等,人跟系统交互的窗口。
数据流有两类:一类是“上行数据”,传感器把温度、湿度、开关状态通过GPIO或BLE送到ESP32,ESP32打包后通过WiFi/MQTT发到服务端;另一类是“下行控制”,用户在手机或网页上点一下,请求经WiFi到达ESP32,ESP32判断后驱动继电器、开关或通过BLE转发控制指令给蓝牙设备。
这里有个关键设计心得:ESP32不直接连互联网也能工作。我把整个系统做成了局域网优先,所有控制指令先走本地MQTT或本地Web,只有需要远程查看时才通过加密隧道或云服务中转。这样即使断网,家里最基本的开关控制、传感器读取不受影响。很多人做智能家居一上来就搞“云平台”,结果外网一抖设备就全失控,这是最不值得踩的坑。
2.3 选型对比:ESP32对比树莓派、STM32,优势在哪
你可能也看到热搜里有“基于树莓派的智能家居”“STM32智能家居系统”,这里比较一下方便选型。树莓派的优势是跑完整Linux,能装Python、Docker、Home Assistant,扩展性极强,但代价是功耗高、体积大、开机慢,更重要的是价格贵。STM32功耗低、实时性好、非常适合纯粹的采集控制,可它要自己做网络协议栈,想同时挂WiFi和BLE基本得上双芯片方案,开发成本直接拉高。ESP32属于中间路线:双核240MHz、WiFi和BLE双协议栈都在片内,支持Arduino、ESP-IDF、MicroPython,功耗比树莓派低很多,性能又比传统单片机强不少。做智能家居节点,它是最不容易出错的选项。
这里补充一句,ESP32也有很多型号:经典款ESP32、ESP32-S3、ESP32-C3等。经典款性价比最高,外设全;S3带AI加速和更多IO,适合摄像头和语音类应用;C3是单核RISC-V,成本更低。我主力用的是经典ESP32 DevKit,兼顾价格和资料丰富度。
3. 硬件选型与环境搭建:别在硬件上省不该省的钱
3.1 开发板、传感器和执行器的选择清单
这块我列一下我实际用下来的组合,照抄就能跑:
- ESP32开发板:ESP32 DevKit V1或者NodeMCU-32S,带USB转串口芯片(CP2102或CH340都行),几块钱到三十块钱不等,买的时候留意引脚标注清晰不清晰。
- USB数据线:一定要选带数据传输能力的线,很多手机充电线只有电源没有数据信号,插上后电脑根本识别不到串口。这个坑我见得太多了。
- 温湿度传感器:DHT11或DHT22(也叫AM2302)。DHT11便宜但精度低,适合演示;DHT22精度高,适合认真测量环境。传感器供电接3.3V或5V都行,注意DHT22的数据线要接一个4.7kΩ上拉电阻到VCC。
- 继电器模块:市面上常见的有1路、2路、4路,用光耦隔离的版本更好。驱动电压选5V或3.3V的,注意ESP32的GPIO电平是3.3V,最好买低电平触发的继电器模块,IO口默认高电平更安全。
- 红外人体传感器:HC-SR501,检测人体移动,做自动化触发很好用。
- BLE终端设备:可以用另一块ESP32、或者HM-10/CC2541这类BLE透传模块,模拟低功耗节点。
说下接线。DHT22我接在GPIO4,继电器接在GPIO25和GPIO26,人体红外接在GPIO13(这个引脚同时连接了板载LED,方便调试)。VCC和GND统一接好,传感器供电和逻辑供电共地。这个共地很重要,否则数字信号可能乱跳。
3.2 开发环境:Arduino IDE还是PlatformIO?
ESP32的开发环境主要有三种:Arduino IDE、PlatformIO、ESP-IDF。我的建议是刚上手用Arduino IDE,逻辑简单,库多,搜“ESP32装库”能找到大量资料;但如果你后续项目变大,强烈建议切到PlatformIO,它能管理多环境、多平台,编译产物干净,用VSCode写代码的体验比Arduino IDE舒服太多。这篇博客里的代码我把依赖库都列出来,两种环境都能用。
Arduino IDE安装ESP32支持,有个容易卡住的地方是下载官方板卡包太慢。解决办法是在“文件→首选项→附加开发板管理器网址”里填国内镜像源,然后到开发板管理器里搜esp32安装。我自己用阿里云镜像,地址就是那串常用的:
https://espressif.github.io/arduino-esp32/package_esp32_index.json https://download.espressif.com/package_esp32_index.json如果自动下载特别慢,你还可以手动下载乐鑫官方固件包放到本地的Arduino15目录。很多教程提到这个,记住一句话:不要反复重试官方源,换镜像是最有效的方法。
依赖库方面,我主要用:
- WiFi.h(内置)
- PubSubClient.h(MQTT客户端)
- WebServer.h(内置)
- ArduinoJson.h(处理JSON数据)
- DHT sensor library + Adafruit Unified Sensor(驱动DHT系列)
- BLEDevice.h(内置,BLE GATT相关)
4. 核心实现:WiFi通信与数据上报
4.1 WiFi连接:不仅要能连上,还要能扛断线
WiFi连接本身不复杂,Arduino里调用WiFi.begin(ssid, password)就行。但在智能家居里,路由器重启、信号波动太常见了,默认的连接方式一旦掉线就可能卡死。所以我自己封装了一层WiFiManager逻辑:
- 上电先尝试连接上一次保存的WiFi配置;
- 连不上就进入配网模式,用内置WebServer开一个热点或回调页面,让用户填新的WiFi账号密码;
- 运行期间循环检测WiFi状态,如果断开就重新连接,连接次数有上限,超过就重启重启。
代码大致是这样:
#include <WiFi.h> const char* ssid = "your_ssid"; const char* password = "your_password"; unsigned long lastReconnectAttempt = 0; void setupWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.print("连接WiFi"); while (WiFi.status() != WL_CONNECTED && millis() < 15000) { delay(500); Serial.print("."); } if (WiFi.status() == WL_CONNECTED) { Serial.println("\n已连接,IP: " + WiFi.localIP().toString()); } else { Serial.println("\n连接失败,进入配网模式"); startConfigPortal(); } } void keepWiFiAlive() { if (WiFi.status() != WL_CONNECTED && millis() - lastReconnectAttempt > 30000) { lastReconnectAttempt = millis(); WiFi.disconnect(); WiFi.begin(ssid, password); Serial.println("正在重连WiFi..."); } }这里有一个细节:重连前一定要WiFi.disconnect()。因为ESP32的WiFi栈偶尔会进入一种“假连接”状态——状态码显示已连接,实际上已经收不到数据了。断开重连相当于给它一次重新初始化协议栈的机会。另外,如果你的板子离路由器特别远,试试2.4GHz频段的固定信道,不要用自动信道扫描,自动扫描碰上网段拥挤时容易超时。
4.2 用MQTT做消息通道,而不是裸Socket
我最早做这套系统时用TCP Socket自己定协议,后来发现每加一个功能就要改协议、改客户端,非常繁琐。换成MQTT清爽很多:它本质上是一个基于发布/订阅的消息中间件,适合传感器这种低频小数据。我本地跑了一个Mosquitto MQTT Broker,ESP32作为客户端发布主题,手机和PC订阅主题,所有设备共用一套消息格式,模块之间完全解耦。
ESP32端用PubSubClient库,关键代码:
#include <PubSubClient.h> WiFiClient espClient; PubSubClient mqttClient(espClient); const char* mqttServer = "192.168.1.10"; // 本地MQTT服务器地址 const int mqttPort = 1883; void connectMQTT() { mqttClient.setServer(mqttServer, mqttPort); mqttClient.setCallback(mqttCallback); while (!mqttClient.connected()) { Serial.print("连接MQTT..."); if (mqttClient.connect("ESP32_Gateway", "user", "pass")) { Serial.println("成功"); mqttClient.subscribe("home/control/#"); } else { Serial.print("失败,错误码: "); Serial.println(mqttClient.state()); delay(2000); } } } void mqttCallback(char* topic, byte* payload, unsigned int length) { String msg = String((char*)payload).substring(0, length); Serial.printf("收到主题 %s 消息 %s\n", topic, msg.c_str()); if (String(topic) == "home/control/relay1") { if (msg == "ON") { digitalWrite(RELAY1_PIN, LOW); mqttClient.publish("home/status/relay1", "ON"); } else if (msg == "OFF") { digitalWrite(RELAY1_PIN, HIGH); mqttClient.publish("home/status/relay1", "OFF"); } } }这里特别提醒一下PubSubClient的坑:如果你在loop()里调用了很多delay(),MQTT的心跳就会被卡住,服务器以为客户端掉线,然后断开连接。正确的做法是使用类似millis()的非阻塞定时器来处理采集和上报,让loop()尽量快地循环。我见过不少人折腾半天MQTT时好时坏,最后发现原因就是delay(1000)塞在消息推送函数里面了。
4.3 内嵌Web页面:不用装App也能控制
手机装App确实方便,但对于临时访客、家人改动设置来说,一个内嵌网页是最省心的。ESP32的WebServer库可以托管静态HTML,也可以处理表单和GET/POST请求。我做了两个页面:一个是仪表盘,实时显示温湿度和继电器状态;另一个是配置页,用来调整上报间隔、设置阈值。
核心逻辑:
#include <WebServer.h> WebServer server(80); void handleRoot() { String html = "<html><head><meta charset='UTF-8'><title>ESP32 智能家居</title></head><body>" "<h2>ESP32 状态</h2>" "<p>温度: " + String(tempC, 1) + " ℃</p>" "<p>湿度: " + String(humidity, 1) + " %</p>" "<p>继电器1: " + String(relay1State ? "ON" : "OFF") + "</p>" "<p><a href='/relay1/on'>打开继电器1</a></p>" "<p><a href='/relay1/off'>关闭继电器1</a></p>" "</body></html>"; server.send(200, "text/html", html); } void setupWebServer() { server.on("/", handleRoot); server.on("/relay1/on", []() { digitalWrite(RELAY1_PIN, LOW); relay1State = true; server.send(200, "text/plain", "OK"); }); server.on("/relay1/off", []() { digitalWrite(RELAY1_PIN, HIGH); relay1State = false; server.send(200, "text/plain", "OK"); }); server.begin(); }对于局域网访问,这样的页面已经够用。如果你希望外网也能访问,千万不要直接把80端口映射出去——ESP32的Web服务很脆弱,裸奔公网容易被扫描攻击。我自己的做法是:ESP32只往本地MQTT上报,外网用户通过Nginx反向代理Home Assistant之类的前端服务,由前端再走内网MQTT控制ESP32,这样ESP32永远不直接暴露到公网。
5. 核心实现:BLE蓝牙通道接入与离线控制
5.1 用BLE GATT定义设备服务
ESP32的BLE功能基于GATT协议,简单理解就是定义一组“服务”和“特征值”,手机上的蓝牙App通过读写这些特征值来获取状态或下发控制。我定义了一个自定义服务(UUID自己生成),包含一个温湿度特征值,一个继电器控制特征值。
关键代码:
#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> #include <BLE2902.h> #define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dc9a91" #define CHAR_TEMP_UUID "6e400002-b5a3-f393-e0a9-e50e24dc9a91" #define CHAR_RELAY_UUID "6e400003-b5a3-f393-e0a9-e50e24dc9a91" BLECharacteristic *tempChar; BLECharacteristic *relayChar; class RelayCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) override { std::string value = pCharacteristic->getValue(); if (value.length() == 2) { if (value[0] == 'O' && value[1] == 'N') { digitalWrite(RELAY1_PIN, LOW); } else if (value[0] == 'O' && value[1] == 'F' && value[2] == 'F') { digitalWrite(RELAY1_PIN, HIGH); } } } }; void setupBLE() { BLEDevice::init("ESP32 SmartHome"); BLEServer *server = BLEDevice::createServer(); BLEService *service = server->createService(SERVICE_UUID); tempChar = service->createCharacteristic( CHAR_TEMP_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); relayChar = service->createCharacteristic( CHAR_RELAY_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE ); relayChar->setCallbacks(new RelayCallbacks()); service->start(); BLEAdvertising *advertising = server->getAdvertising(); advertising->addServiceUUID(SERVICE_UUID); advertising->setScanResponse(true); advertising->start(); }注意我给特征值开启了NOTIFY属性,这样温湿度变化时ESP32可以主动推送数据到手机,省得手机反复主动去读,既省电又实时。
5.2 用手机App控制:推荐nRF Connect调试,再用小程序收尾
调试BLE时我强烈推荐先用nRF Connect这个工具,它能扫描到设备、查看服务列表、读写特征值,是最直观的调试利器。连接上ESP32之后,你能直接看到“即时温度”“湿度”“继电器控制”这些特征值,手动写入字符串就能测试,完全不依赖自己写的代码。
真正给家人用时,蓝牙App我用微信小程序实现,因为免安装、跨平台。小程序里调用wx.openBluetoothAdapter、wx.getBLEDeviceServices这些API去连ESP32,逻辑跟原生App一样。小程序的好处是二维码一发就能用,而且iOS和Android体验基本一致;坏处是蓝牙API的兼容性细节多,比如某些Android机型需要在“定位服务”打开后才能扫描到设备。这个坑让我排查了一下午,后来疯狂吐槽,不过也吸取了一个教训:BLE调试永远把手机系统权限当成第一排查项。
5.3 单芯片双协议栈协同:注意共存问题
ESP32同时启用WiFi和BLE是支持的,但两者共用同一个2.4GHz射频前端,极端情况下会互相抢资源。实际表现是“同时大量收发WiFi数据时,BLE连接延迟变高”。对智能家居这种低频应用来说,感知不明显。不过要注意几个发力点:
- 如果应用场景是WiFi跑OTA固件升级,同时BLE在传输关键控制信息,最好把BLE暂时断开或把WiFi的吞吐限制一下。
- 在ESP32的Arduino环境下,默认配置已经在BT控制器里做了共存仲裁,不用自己改。
- 如果遇到明显卡顿,优先检查电源:WiFi发射瞬间电流可达300mA以上,如果USB线太长或供电能力不足,电压会被拖低,此时各种诡异问题都会出来。
6. 实战场景:温湿度监测、继电器控制与自动化联动
6.1 采集温湿度并定时上报
DHT22的读取逻辑非常简单,但很多人忽略一个问题:DHT系列传感器从初始化到第一次读取要间隔至少1秒,否则数据会是零。我自己写的采集函数多做了几重保护,连续读到NaN时保留上一次有效值,同时记录错误计数,方便判断传感器是不是真的挂了。
#include <DHT.h> #define DHT_PIN 4 #define DHT_TYPE DHT22 DHT dht(DHT_PIN, DHT_TYPE); void readSensors() { float h = dht.readHumidity(); float t = dht.readTemperature(); if (isnan(h) || isnan(t)) { Serial.println("读取温湿度失败,保留旧值"); return; } tempC = t; humidity = h; Serial.printf("温度: %.1f ℃, 湿度: %.1f %%\n", tempC, humidity); }上报策略我建议做“变化上报”而不是“固定间隔上报”。比如温度变化超过0.5℃或湿度变化超过1%才上报,数据频率低了,MQTT服务器和电池供电节点压力都小很多。这个思路对BLE节点尤其重要,频繁上报是最主要的耗电元凶。
6.2 继电器控制:低电平触发和高电平触发的坑
继电器模块看起来简单,实际很容易栽。很多低价继电器模块是“低电平触发”,也就是GPIO输出LOW时继电器吸合,GPIO输出HIGH时断开。原因在于这类模块使用了PNP三极管或者光耦反相设计,设计初衷是兼容MCU上电瞬间IO默认高电平,避免误动作。我在代码里因此写成了数字逻辑取反:控制继电器开就要digitalWrite(LOW),关反而是HIGH。如果你的模块是高电平触发,代码反过来。
另外,继电器控制感性负载(比如电机、变压器)时,触点通断会产生火花,会干扰ESP32的模拟信号。能做的措施包括:负载两端并联阻容吸收电路,继电器模块用光耦隔离版,传感器线和继电器线分开走线不要绞在一起。
6.3 外部中断实现感应联动
红外人体传感器HC-SR501输出的是简单的数字信号,有人的时候输出高电平。但如果你在loop()里用digitalRead去轮询,会浪费大量CPU,还可能漏掉脉冲。正确的做法是用外部中断:
#define PIR_PIN 13 volatile bool motionDetected = false; void IRAM_ATTR pirInterrupt() { motionDetected = true; } void setup() { pinMode(PIR_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIR_PIN), pirInterrupt, RISING); } void loop() { if (motionDetected) { motionDetected = false; Serial.println("检测到人体运动,开灯"); digitalWrite(RELAY1_PIN, LOW); // 开灯 } }这里有个重要的知识点:中断处理函数里不能调用delay()、Serial.println()这类耗时函数,因为它们不是在主循环上下文中执行的。正确做法是中断里只置标志位,主循环快速判断并处理动作。这个习惯能避免大量莫名其妙的死机和复位问题。
这套系统最爽的使用场景:人一走进房间,传感器通过中断唤醒ESP32,ESP32开灯并把MQTT消息发给其他房间节点;温度高于28℃自动打开风扇的继电器;深夜2点以后再检测到人体运动,只推送告警不上报灯控。所有自动化逻辑都写在ESP32端,断网也照跑不误。
7. 常见问题与排查:我踩过的坑和速查表
7.1 烧录失败:不是代码问题,往往是串口和boot模式问题
ESP32最常遇到的报错是“A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header”。新手第一反应是代码写错了,其实大概率是板子没有进入下载模式。ESP32的下载流程需要把EN引脚短暂拉低再释放,同时保持GPIO0为低电平。很多开发板设计成“按住BOOT键,再按一下EN键,松开BOOT键”就能进入下载模式。Arduino上传时如果看到Connecting...提示,操作这个按键组合就行了。
还有一种情况是USB转串口芯片驱动没装好,尤其CH340在Windows下需要手动装驱动,Mac和Linux一般免驱。在设备管理器里看看COM口是否出现,如果没有,换根线或者手动装驱动。
7.2 WiFi连接不稳定:排查电源,再看路由器
按我以往经验,ESP32 WiFi不稳定大约有七成是供电问题:USB线过长、电脑USB口供电不足、或者用充电宝供电时电流限制太大。ESP32在TCP传输瞬间电流会冲到300mA以上,如果你从某个树莓派GPIO引脚或PC前置USB口取电,电压很容易被拉低到2.7V左右,随之而来的是WiFi掉线、重启循环、Flash写入失败。最稳的供电方案是独立5V/2A电源适配器给开发板供电,或用优质的USB线接到独立充电头。
排除供电后,再考虑WiFi信道冲突。ESP32只能连接2.4GHz网络,如果搜索不到你的WiFi,可能是路由器只开了5GHz频段。把路由器打开双频合一之前,最好先给ESP32连接一个单独命名的2.4GHz SSID,免得手机连了5GHz后找不到设备。
7.3 BLE扫描不到设备:权限和广播参数优先检查
手机扫描不到ESP32,九成不是代码问题。iOS和Android现在都对蓝牙扫描增加了严格权限控制,Android要求同时打开“定位服务”并且赋予App定位权限(因为蓝牙扫描被归类为基于位置的服务)。这个点特别反直觉。另外Android 12以上还需要在“附近设备”权限中单独授权。
排除权限问题后,再检查ESP32的广播参数。BLE广播名称、广播间隔都要设置正确,广播数据里建议把Service UUID加进去,这样手机端过滤服务UUID就能快速找到设备。
7.4 常见问题速查表
| 现象 | 可能原因 | 快速排查 |
|---|---|---|
| 上传固件卡在Connecting | 未进入下载模式 | BOOT+EN组合键重新触发下载 |
| 串口能识别但写着乱码 | 波特率不匹配 | 设置115200或从头看日志 |
| WiFi连接超时 | 供电不足 / 5GHz频段 | 换独立电源、找2.4GHz SSID |
| MQTT时不时掉线 | delay()阻塞消息循环 | 改用非阻塞定时器 |
| BLE扫描不到 | 权限未开 / 广播没启动 | 检查系统权限和广播代码 |
| DHT读数值为NaN | 上电后读取过早 / 接线问题 | 延时2秒再读,检查上拉电阻 |
8. 一些实际使用中的体会和建议
这套项目做完之后,我对“智能家居”这四个字的理解变得更务实了。真正让人舒服的不是设备能连上网,而是它能按人的习惯自动运转,并且出问题时知道去哪查、怎么修。我建议第一次做的人,先不要追求功能全,把任何一个单一功能(比如“用手机控制一个继电器”)跑通,拥有自己调试全链路的能力,再逐步往上加温度传感器、湿度、人体感应、BLE子网关,心态会稳很多。
另外,这套方案后续扩展的想象空间也很大。我正打算在ESP32上再接一块OLED小屏显示当前状态,顺便把数据同步到Home Assistant;如果后续接小车或机器人类项目,也可以用ESP32做串口桥接,跟ROS 2 Humble通信,不过那就是另一个项目了。至少在当前这套智能家居系统里,ESP32只花了不到五十块钱,却同时把WiFi和BLE两条路走通了,这种把钱花在刀刃上的感觉,真的很爽。