一、引言
在ESP32物联网项目中,设备需要连接WiFi才能进行数据上报、OTA升级和远程控制。传统的WiFi配置方式有两种:
- 硬编码SSID和密码:将WiFi凭据直接写在固件中,用户无法修改,产品化程度低。
- SoftAP配网:ESP32开启热点,用户连接热点后通过浏览器配置。该方式需要用户切换网络,且手机可能自动切回移动数据,导致配置页面无法打开。
蓝牙BLE配网通过蓝牙将WiFi凭据发送给ESP32,用户无需切换网络,操作流程更简洁。ESP32内置BLE 4.2,无需额外硬件即可实现。本文基于Arduino IDE和NimBLE-Arduino库,实现完整的BLE配网流程,包括接收WiFi信息、连接路由器、回传状态、保存至NVS,并在下次上电时自动重连。
二、整体通信流程
BLE配网的核心流程如下:
- ESP32开启BLE广播,等待手机App连接。
- 手机App扫描到设备并建立BLE连接。
- App向ESP32的写特征值(Write Characteristic)发送JSON格式的WiFi信息。
- ESP32解析JSON,提取SSID和密码,尝试连接路由器。
- ESP32通过通知特征值(Notify Characteristic)将配网结果回传给App。
- 连接成功后,ESP32将WiFi凭据保存到NVS(非易失性存储),下次上电自动重连。
整个过程中,BLE仅用于传输WiFi凭据和状态,实际数据通信仍由WiFi完成。
三、环境准备
硬件:
· 任意ESP32开发板(ESP32-WROOM-32、ESP32-S3、ESP32-C3等均可)。
软件:
· Arduino IDE。
· ESP32开发板支持包(通过开发板管理器安装)。
· 库:
· NimBLE-Arduino(作者h2zero):轻量级BLE库,编译速度快,RAM占用小。
· ArduinoJson(作者bblanchon):用于解析和生成JSON数据。
在Arduino IDE的库管理器中搜索并安装上述两个库即可。
四、完整代码
以下代码可直接复制到Arduino IDE中编译上传。代码实现了最小可用的BLE配网功能,包含NVS保存和自动重连。
#include<NimBLEDevice.h>#include<WiFi.h>#include<ArduinoJson.h>#include<Preferences.h>// 自定义Service UUID和Characteristic UUID// 需与手机App端保持一致#defineSERVICE_UUID"0000FFF0-0000-1000-8000-00805F9B34FB"#defineCHAR_WRITE_UUID"0000FFF1-0000-1000-8000-00805F9B34FB"// App写入WiFi信息#defineCHAR_NOTIFY_UUID"0000FFF2-0000-1000-8000-00805F9B34FB"// ESP32回传状态boolprovisioned=false;Preferences prefs;NimBLECharacteristic*pNotifyChar=nullptr;// WiFi连接函数,超时时间默认15秒boolconnectWiFi(constchar*ssid,constchar*password,unsignedlongtimeoutMs=15000){WiFi.begin(ssid,password);unsignedlongstart=millis();while(WiFi.status()!=WL_CONNECTED&&millis()-start<timeoutMs){delay(500);Serial.print(".");}if(WiFi.status()==WL_CONNECTED){Serial.println("\nWiFi connected, IP: "+WiFi.localIP().toString());returntrue;}Serial.println("\nWiFi connection failed");returnfalse;}// 通过通知特征值发送状态给AppvoidsendStatus(constchar*status,constchar*message){if(!pNotifyChar)return;StaticJsonDocument<128>doc;doc["status"]=status;doc["message"]=message;String jsonStr;serializeJson(doc,jsonStr);pNotifyChar->setValue(jsonStr.c_str());pNotifyChar->notify();Serial.println("Notify: "+jsonStr);}// BLE写回调,处理App发来的WiFi信息classProvisioningCallback:publicNimBLECharacteristicCallbacks{voidonWrite(NimBLECharacteristic*pCharacteristic)override{std::string value=pCharacteristic->getValue();if(value.empty())return;Serial.println("Received: "+String(value.c_str()));StaticJsonDocument<256>doc;DeserializationError err=deserializeJson(doc,value);if(err){sendStatus("error","JSON parse failed");return;}constchar*ssid=doc["ssid"];constchar*password=doc["password"];if(!ssid||!password){sendStatus("error","Missing ssid or password");return;}sendStatus("connecting","Connecting to WiFi...");if(connectWiFi(ssid,password)){// 保存到NVSprefs.begin("wifi_cfg",false);prefs.putString("ssid",ssid);prefs.putString("password",password);prefs.end();sendStatus("success",WiFi.localIP().toString().c_str());provisioned=true;}else{sendStatus("error","WiFi connection failed, check credentials");}}};voidsetup(){Serial.begin(115200);Serial.println("ESP32 BLE Provisioning starting...");// 读取NVS中保存的WiFi信息prefs.begin("wifi_cfg",true);String savedSSID=prefs.getString("ssid","");String savedPass=prefs.getString("password","");prefs.end();if(savedSSID.length()>0){Serial.println("Found saved WiFi: "+savedSSID);if(connectWiFi(savedSSID.c_str(),savedPass.c_str(),10000)){provisioned=true;return;// 已连接,无需BLE配网}Serial.println("Saved WiFi failed, entering provisioning mode");}// 初始化BLENimBLEDevice::init("ESP32_Config");// BLE广播名NimBLEServer*pServer=NimBLEDevice::createServer();NimBLEService*pService=pServer->createService(SERVICE_UUID);// 写特征值,用于接收App数据NimBLECharacteristic*pWriteChar=pService->createCharacteristic(CHAR_WRITE_UUID,NIMBLE_PROPERTY::WRITE|NIMBLE_PROPERTY::WRITE_NR);pWriteChar->setCallbacks(newProvisioningCallback());// 通知特征值,用于回传状态pNotifyChar=pService->createCharacteristic(CHAR_NOTIFY_UUID,NIMBLE_PROPERTY::NOTIFY);pService->start();// 开始广播NimBLEAdvertising*pAdvertising=NimBLEDevice::getAdvertising();pAdvertising->addServiceUUID(SERVICE_UUID);pAdvertising->setScanResponse(true);pAdvertising->start();Serial.println("BLE advertising started, waiting for app...");}voidloop(){if(provisioned){// 配网成功后执行业务逻辑,例如MQTT连接、传感器读取等delay(1000);}}五、代码详细说明
5.1 UUID定义
SERVICE_UUID、CHAR_WRITE_UUID、CHAR_NOTIFY_UUID 三个UUID需要与手机App端保持一致。本文使用标准的128位UUID格式,实际项目中可以自定义。
5.2 写特征值与通知特征值
· 写特征值:属性为 WRITE | WRITE_NR,允许App写入数据。WRITE_NR 表示无响应写入,提高传输效率。
· 通知特征值:属性为 NOTIFY,ESP32通过它主动向App推送状态。App端需要订阅该特征值才能收到通知。
5.3 回调函数 onWrite
当App向写特征值写入数据时,onWrite 被触发。代码从 pCharacteristic->getValue() 获取数据,使用ArduinoJson解析。JSON格式为:
{"ssid":"YourWiFi","password":"YourPassword"}解析成功后提取SSID和密码,调用 connectWiFi 连接路由器。连接结果通过 sendStatus 回传,成功时将凭据保存到NVS。
5.4 WiFi连接与超时处理
connectWiFi 函数调用 WiFi.begin 后轮询 WiFi.status(),超时时间可配置。默认15秒,自动重连时使用10秒。超时后返回失败。
5.5 NVS保存与自动重连
使用 Preferences 库将SSID和密码保存到NVS分区。setup 中首先读取NVS,若存在则尝试连接。连接成功则直接进入业务逻辑,跳过BLE配网;失败则进入配网模式。
5.6 BLE初始化与广播
NimBLEDevice::init(“ESP32_Config”) 设置BLE设备名。创建服务和特征值后,通过 pAdvertising->addServiceUUID 将服务UUID加入广播包,setScanResponse(true) 允许扫描响应,最后 start() 开始广播。
六、常见问题与解决方案
6.1 WiFi.SSID()在冷启动时返回空字符串
现象:ESP32重启后调用 WiFi.SSID() 返回空字符串,无法判断设备是否已配网。
原因:ESP32的WiFi库在未建立连接时不会保存上次连接的SSID。
解决方案:使用 Preferences 手动保存SSID和密码,启动时从NVS读取,而不是依赖 WiFi.SSID()。
6.2 密码错误时无法区分具体错误
现象:WiFi连接失败,但无法判断是密码错误、信号弱还是路由器未响应。
原因:WiFi协议在四次握手阶段不提供具体的错误码,设备只能等待超时。
解决方案:设置合理的超时时间(建议10-15秒),超时后统一提示“连接失败,请检查凭据和信号”。若需要更精确的错误信息,可尝试分析 WiFi.status() 的返回值,但ESP32的WiFi库支持有限。
6.3 BLE广播名与WiFi SSID冲突
现象:手机同时扫描到两个同名设备,用户无法区分。
原因:若ESP32同时开启了WiFi AP模式,其SSID可能与BLE广播名相同。
解决方案:BLE广播名使用独立命名,例如 ESP32_Config_XXXX(XXXX为MAC地址后四位),避免与WiFi SSID重复。
6.4 NimBLE与Arduino自带BLE库不兼容
现象:同时包含 <BLEDevice.h> 和 <NimBLEDevice.h> 导致编译错误。
原因:两个库的类名和宏定义存在冲突。
解决方案:仅使用NimBLE-Arduino库,移除Arduino自带的BLE库。将所有 BLEDevice 替换为 NimBLEDevice,BLECharacteristic 替换为 NimBLECharacteristic,依此类推。
6.5 通知特征值未订阅导致notify无效
现象:ESP32调用 notify() 后,App端未收到任何数据。
原因:BLE客户端必须使能通知(写入CCCD描述符)才能接收通知。
解决方案:App端连接后需主动订阅通知特征值。ESP32端确保特征值属性包含 NOTIFY,并且 pNotifyChar 已正确初始化。
6.6 NVS写入频率与寿命
现象:频繁保存WiFi凭据可能导致NVS分区磨损。
原因:NVS基于Flash存储,写入次数有限(通常约10万次)。
解决方案:仅在配网成功时写入一次,日常运行中不重复写入。如果需要更新凭据,先比较新旧值,相同则不写入。
6.7 广播未启动或未添加Service UUID
现象:手机扫描不到ESP32设备。
原因:未调用 startAdvertising() 或未添加Service UUID到广播包。
解决方案:确保在 setup 中调用 pAdvertising->addServiceUUID(SERVICE_UUID) 和 pAdvertising->start()。部分手机要求广播包中包含Service UUID才能显示设备。
6.8 JSON解析内存不足
现象:deserializeJson 返回 NoMemory 错误。
原因:StaticJsonDocument 容量设置过小。
解决方案:根据实际JSON大小调整容量。本文接收的JSON包含ssid和password,256字节足够。发送状态时使用128字节。如果增加Token等字段,需相应增大。
七、安全建议
- 配网Token验证:在JSON中增加Token字段,ESP32验证通过后才执行配网,防止未授权设备连接。
- 配网成功后关闭BLE:调用 NimBLEDevice::deinit() 释放BLE资源,降低功耗并提高安全性。
- 使用加密连接:NimBLE支持BLE加密,可在配对时要求输入密码或使用加密特征值。
- 限制配网时间:设置配网超时,例如5分钟后自动关闭BLE广播,避免长期暴露。
八、总结
本文实现了基于NimBLE-Arduino的ESP32 BLE配网方案,包含完整的代码和常见问题分析。核心流程为:广播 → 接收WiFi信息 → 连接路由器 → 回传状态 → 保存NVS。代码可直接复制使用,适用于ESP32全系列开发板。后续可在此基础上扩展MQTT通信、OTA升级等功能,构建完整的物联网设备框架。
参考文档:
· Espressif官方文档:BLE Provisioning
· NimBLE-Arduino库GitHub仓库
· ArduinoJson官方文档