巴法app蓝牙配网esp32
大家好,这篇文章整理一下巴法 App 蓝牙配网在 ESP32 开发板上的接入方法。很多人做智能硬件时,都会遇到一个很现实的问题:设备第一次上电时还没有联网,怎么让用户把家里的 WiFi 信息配进去?
如果你用的是 ESP32,那么蓝牙配网是一个比较合适的方案。设备先通过 BLE 广播自己,手机 App 搜索到设备后,把 WiFi 名称、密码和巴法云私钥通过蓝牙发给设备,设备连网成功后再回传结果,整个流程会比 SoftAP 更直接,用户体验也更清晰。
这篇文章会尽量用开发者视角,把协议、流程、实现重点和示例工程讲清楚,适合正在做巴法云设备接入的同学参考。
先放下载地址
想直接拿工程的,可以先看这里。下面这几个都是示例程序代码下载链接,直接点就能下。
同时支持 “ 一键配网”小程序配网
最小 BLE 配网示例下载
NimBLE 简化版:http://file.bemfa.com/zip/esp32/ble/blu_NimBLE.zip
经典 BLE 版本:https://file.bemfa.com/zip/esp32/ble/blu.zip
BLE 配网 + TCP 示例下载
TCP 版本:https://file.bemfa.com/zip/esp32/ble/blu_tcp.zip
BLE 配网 + MQTT 示例下载
MQTT 版本:https://file.bemfa.com/zip/esp32/ble/blu_mqtt.zip
全部代码下载链接汇总
- 最小 NimBLE 配网版:http://file.bemfa.com/zip/esp32/ble/blu_NimBLE.zip
- 经典 BLE 配网版:https://file.bemfa.com/zip/esp32/ble/blu.zip
- BLE + TCP 整合版:https://file.bemfa.com/zip/esp32/ble/blu_tcp.zip
- BLE + MQTT 整合版:https://file.bemfa.com/zip/esp32/ble/blu_mqtt.zip
如果你准备直接接 MQTT,建议先把最小配网版跑通,再切到 MQTT 版继续集成。

本文直接给完整示例代码
为了方便大家直接测试,下面我把一份适合 ESP32 Arduino + NimBLE 的最小蓝牙配网示例程序完整放出来。
这个示例实现了这些功能:
- BLE 广播
- 固定 Service UUID / Characteristic UUID
- 处理
hello - 处理
wifi - 回传
status - 处理
finish - 保存 WiFi 信息
你只要安装好依赖库,复制到 Arduino IDE 里就可以开始调试。
一、蓝牙配网整体流程
巴法 App 蓝牙配网的核心流程可以概括成 6 步:
- ESP32 进入待配网模式并开启 BLE 广播
- 巴法 App 搜索附近可配网设备
- 用户选择设备并建立蓝牙连接
- App 向设备发送 WiFi 信息和巴法云 token
- ESP32 连接路由器并尝试连接云端
- 设备通过 BLE 回传状态,最终结束配网
通信流程如下:
App ESP32| || 扫描蓝牙广播 ||-------------------------------->|| || 建立 BLE 连接 ||-------------------------------->|| || 发送 hello ||-------------------------------->||<--------------------------------|| || 发送 wifi ||-------------------------------->||<--------------------------------|| || 接收 status 状态 ||<--------------------------------||<--------------------------------|| || 发送 finish ||-------------------------------->||<--------------------------------|
从实现上看,这套方案不复杂,ESP32 侧只要把 BLE GATT 的 WRITE 和 NOTIFY 跑通,就能完成基本配网。
二、为什么 ESP32 适合做蓝牙配网
ESP32 同时支持 WiFi 和 BLE,这一点非常适合做首配网场景。相比让设备起热点,蓝牙配网有几个明显优点:
- 用户不需要手动切换手机 WiFi
- 交互链路更短,体验更自然
- 协议结构清晰,适合统一 App 和小程序接入
- 很适合灯、插座、开关、传感器等低功耗或小型设备
如果你的项目已经在用巴法云控制设备,那么把首次联网入口统一到蓝牙配网,会比自己再单独维护一套配网协议省事很多。
三、ESP32 需要实现的 BLE GATT 服务
设备端需要实现 1 个 Service 和 2 个 Characteristic。
Service UUID: BEFA2000-BE35-4A5A-9C4A-BE0E02000000RX Characteristic UUID: BEFA2001-BE35-4A5A-9C4A-BE0E02000000
属性: WRITE
方向: App -> DeviceTX Characteristic UUID: BEFA2002-BE35-4A5A-9C4A-BE0E02000000
属性: NOTIFY
方向: Device -> App
这里的设计很直接:
RX用来接收 App 发送过来的 JSON 指令TX用来通知设备状态和响应结果- App 扫描设备时,通过固定的 Service UUID 过滤出待配网设备
对于 ESP32 来说,这样的 GATT 设计已经足够简洁,后续扩展也方便。
四、广播包怎么设计
为了让巴法 App 更容易识别设备,ESP32 的 BLE 广播建议遵循下面的规则。
1. 广播名称
推荐格式:
Bemfa_<短码>
例如:
Bemfa_A3F2B2
2. 广播中携带服务 UUID
广播中应带上完整的 128 位 Service UUID:
BEFA2000-BE35-4A5A-9C4A-BE0E02000000
3. 建议带 Manufacturer Data
推荐的 Manufacturer Data 字段如下:
| 字段 | 说明 |
|---|---|
| company_id | 固定 0xBEFA |
| proto_major | 协议主版本,固定 0x02 |
| proto_minor | 协议次版本,固定 0x00 |
| device_type | 设备类型 |
| setup_state | 0=已配网,1=待配网,2=维护配网 |
| capability | 能力位 |
| short_code | 设备短码 |
示例字节:
FA BE 02 00 02 01 11 F2 A3
如果你做的是灯、插座、风扇、开关这类产品,建议在广播阶段就把 device_type 带出来,这样 App 端更容易做设备识别和展示。
五、App 和 ESP32 之间传什么数据
巴法 App 和 ESP32 之间的数据格式采用 UTF-8 编码的 JSON 字符串,通过 BLE Characteristic 收发。
通用字段:
| 字段 | 说明 |
|---|---|
| cmd | 命令名称 |
| seq | 请求序号 |
| code | 响应码,0 为成功 |
注意几点:
- 字段名统一使用小写加下划线
- 不要发送
null - 日志中不要打印 WiFi 密码和 token
- 连续发送多条 JSON 时,建议每条消息至少间隔
200ms
这个 200ms 间隔很关键,特别是 ESP32 在短时间内连续上报 status 和 finish 时,如果发得太快,手机端有概率处理不及时,表现出来就是状态丢失或页面没及时刷新。
六、蓝牙配网的 4 个核心命令
1. hello
App 先发一个 hello,用于双方确认协议和能力。
App -> Device:
{"cmd": "hello","seq": 1,"ver": 2,"app": "behome","mtu": 185
}
Device -> App:
{"cmd": "hello","seq": 1,"code": 0,"ver": 2,"device_id": "BH_A1B2C3","device_type": 2,"name": "BeHome Switch","cap": ["wifi", "cloud"]
}
这一步建议在 ESP32 完成后再允许接收 wifi 命令,状态机会更清楚。
2. wifi
用户输入 WiFi 后,App 会把路由器信息和巴法云 token 下发给设备。
{"cmd": "wifi","seq": 3,"ssid": "Home-WiFi","password": "12345678","security": "auto","token": "xxxxxxxxxxxxx"
}
字段里最关键的是这几个:
ssid:路由器名称password:路由器密码token:巴法云用户私钥
设备收到后返回:
{"cmd": "wifi","seq": 3,"code": 0,"accepted": true
}
随后 ESP32 开始联网,并继续上报状态。
3. status
设备通过 status 通知配网进度。
连接中:
{"cmd": "status","seq": 4,"code": 0,"stage": "wifi_connecting","progress": 40
}
连接成功:
{"cmd": "status","seq": 5,"code": 0,"stage": "wifi_connected","progress": 100,"ip": "192.168.1.100","rssi": -45
}
连接失败:
{"cmd": "status","seq": 5,"code": 1001,"stage": "wifi_failed","reason": "wrong_password","retryable": true
}
常见 stage:
receivedwifi_connectingwifi_connectedwifi_failedcloud_connectingcloud_faileddone
4. finish
配网结束后,App 会发送 finish。
{"cmd": "finish","seq": 7
}
设备回复:
{"cmd": "finish","seq": 7,"code": 0,"stage": "done"
}
ESP32 收到 finish 后,应关闭配网广播,并在短时间内断开 BLE 连接。
七、ESP32 端建议的状态机
为了避免逻辑混乱,建议在 ESP32 里做一个简单状态机:
| 状态 | 说明 |
|---|---|
| idle | 等待配网 |
| connected | BLE 已连接 |
| ready | hello 已完成 |
| wifi_received | 已收到 WiFi |
| wifi_connecting | 正在连接 WiFi |
| wifi_connected | WiFi 已连接 |
| done | 配网完成 |
| failed | 配网失败 |
命令约束建议如下:
| 状态 | 允许命令 |
|---|---|
| connected | hello |
| ready | wifi、finish |
| wifi_connected | finish |
| failed | wifi、finish |
如果状态不合法,直接返回错误:
{"cmd": "error","seq": 0,"code": 3001,"reason": "invalid_state"
}
这个约束看起来有点“严格”,但实际上很有必要。很多 ESP32 配网问题,不是 BLE 没连上,而是状态流转没控制好,最后导致设备重复写入、重复联网或者卡死在某一步。
八、ESP32 开发时常见错误码
| 错误码 | reason | 含义 |
|---|---|---|
| 1001 | wrong_password | WiFi 密码错误 |
| 1002 | wifi_not_found | 找不到路由器 |
| 1003 | wifi_timeout | WiFi 连接超时 |
| 1005 | unsupported_wifi | 不支持该 WiFi 类型或频段 |
| 2001 | cloud_failed | 云端连接失败 |
| 3001 | invalid_state | 当前状态不允许该命令 |
| 3002 | invalid_json | JSON 格式错误 |
| 3003 | unsupported_cmd | 不支持的命令 |
| 3004 | payload_too_large | 数据过大 |
| 9001 | internal_error | 设备内部错误 |
做调试时,建议把这些错误码原样打印到串口日志里,定位问题会快很多。
九、ESP32 侧实现要点
设备侧至少需要具备下面这些能力:
- BLE 广播
- GATT Server
- RX
WRITE - TX
NOTIFY - JSON 解析
- WiFi 连接
- 状态回传
另外有几个实现建议很实用:
1. 首次上电自动进入配网模式
如果设备还没有保存 WiFi 信息,建议首次上电自动进入待配网状态。
2. 长按按键重新进入配网
这个功能基本是必备的。因为用户换了路由器或者改了密码后,必须有一个重新配网入口。
3. 配网成功后关闭广播
成功后继续广播没有意义,还会额外耗电,也容易引起重复连接。
4. 保存配网信息
建议至少持久化以下内容:
- WiFi SSID
- WiFi 密码
- 已完成配网标志
十、Arduino IDE 示例工程怎么选
如果你是用 ESP32 Arduino 开发,建议先从最小示例开始,不要一上来就叠加 MQTT、TCP、按键、屏幕、传感器等所有功能。
比较稳的方式是先验证这三件事:
- App 能搜到设备
- App 能把 WiFi 信息发给设备
- 设备能连上网并返回状态
然后再加业务逻辑。
1. 最小 BLE 配网示例
适合首次接入、快速打通流程。
- 推荐版本:
blu_NimBLE.zip - 备用版本:
blu.zip
推荐优先使用 NimBLE 版本,原因很实际:
- 协议实现更轻量
- 更适合真实项目继续扩展
- 后续维护成本更低
如果你编译后提示空间不足,可以在 Arduino IDE 里调整分区方案:
Tools -> Partition Scheme -> Minimal SPIFFS
或者:
Tools -> Partition Scheme -> Huge APP
2. BLE 配网 + TCP 示例
如果你的设备联网后准备直接做 TCP 通信,可以在 BLE 配网成功后接 TCP 逻辑。
3. BLE 配网 + MQTT 示例
如果设备最终接巴法云 MQTT,也可以直接用 BLE + MQTT 的示例结构。
不过这里还是建议一句:
先把“蓝牙配网成功”这条链路单独跑通,再去接 MQTT。否则一旦出问题,你会分不清到底是 BLE 问题、WiFi 问题,还是 MQTT 问题。
十一、推荐安装的 Arduino 库
做 ESP32 蓝牙配网时,常见会用到这些库:
ArduinoJsonAceButtonNimBLE-Arduino
在 Arduino IDE 中可以直接打开:
工具 -> 管理库
然后搜索安装即可。
十二、调试时最容易踩的坑
这里我把开发中最常见的几个坑单独列出来。
1. JSON 连续发送过快
如果 ESP32 连续 notify 多条消息,App 端可能来不及处理。建议每条 JSON 消息之间至少留 200ms 间隔。
2. WiFi 密码和 token 被打到日志里
这个很危险。串口日志、云端日志、调试输出里都不要直接打印敏感字段。
3. 状态机不完整
只要没有限制状态流转,就很容易出现重复配网、异常重连、finish 提前触发等问题。
4. 配网成功后没有及时断开 BLE
这样会导致手机端认为配网还没结束,或者设备一直停留在待配网状态。
5. 没有处理 WiFi 失败重试
建议在 wifi_failed 后允许重新下发 wifi 命令,不要让用户只能重启设备。
十三、一个适合落地的开发顺序
如果你准备自己写 ESP32 蓝牙配网程序,我建议按下面顺序推进:
- 先把 BLE 广播和 GATT 服务搭出来
- 跑通
hello指令 - 跑通
wifi指令和 JSON 解析 - 接入
WiFi.begin()并回传status - 配网成功后保存参数
- 最后再接 TCP、MQTT 或设备业务逻辑
这个顺序的好处是每一步都可以单独验证,排查问题不会乱。
十四、ESP32 Arduino 完整最小示例代码
下面这份代码是一个可直接参考的最小示例,适合你先把巴法 App 蓝牙配网链路跑通。
依赖库:
NimBLE-ArduinoArduinoJsonPreferences
完整代码如下:
// ESP32 BeHome BLE provisioning example.
// Arduino IDE libraries:
// 1. Install "ArduinoJson" from Library Manager.
// 2. Install "NimBLE-Arduino" from Library Manager.
// 3. Use an ESP32 board package that provides WiFi, HTTPClient, Preferences.#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <NimBLEDevice.h>
#include <Preferences.h>
#include <WiFi.h>// =========================
// 需要根据自己产品修改的配置
// =========================
String aptype = "999"; // 001插座,002灯,003风扇,005空调,006开关,009窗帘
String Name = "新设备"; // 设备昵称
String verSion = "3.1"; // 3是tcp设备端口8344,1是MQTT设备
String room = ""; // 房间,例如客厅、卧室等,默认空
int protoType = 3; // 3是tcp设备端口8344,1是MQTT设备
int adminID = 0; // 企业id,默认0// BLE 配网协议里使用的 Service / RX / TX UUID
static const char *SERVICE_UUID = "BEFA2000-BE35-4A5A-9C4A-BE0E02000000";
static const char *RX_UUID = "BEFA2001-BE35-4A5A-9C4A-BE0E02000000";
static const char *TX_UUID = "BEFA2002-BE35-4A5A-9C4A-BE0E02000000";// 使用开发板自带 BOOT 按键做恢复出厂
static const int RESET_BUTTON_PIN = 0; // ESP32 DevKit BOOT button
static const unsigned long RESET_HOLD_MS = 5000;// 设备当前所处的配网状态
enum ProvisionState {STATE_IDLE,STATE_BLE_CONNECTED,STATE_READY,STATE_WIFI_RECEIVED,STATE_WIFI_CONNECTING,STATE_WIFI_CONNECTED,STATE_DONE,STATE_FAILED
};// 持久化保存到 Preferences 的配置结构
// 配网成功后,这些信息重启也不会丢失
struct Config {char stassid[33];char stapsw[65];char cuid[65];char ctopic[33];uint8_t reboot;uint8_t magic;
};// Preferences 用于替代 ESP8266 示例中的 EEPROM
Preferences prefs;
Config config;
WiFiClient bemfaClient;
HTTPClient bemfaHttp;// BLE Server 相关对象
NimBLEServer *bleServer = nullptr;
NimBLECharacteristic *txCharacteristic = nullptr;
NimBLECharacteristic *rxCharacteristic = nullptr;
NimBLEAdvertising *advertising = nullptr;// 运行时状态变量
ProvisionState provisionState = STATE_IDLE;
bool deviceConnected = false;
bool firstWifiConfig = false;
bool configFlag = false;
bool wifiConnectStarted = false;
bool txSubscribed = false;
unsigned long wifiConnectStartMs = 0;
unsigned long wifiLastNotifyMs = 0;
unsigned long wifiLastRetryMs = 0;
unsigned long wifiDebugLastPrintMs = 0;
unsigned long resetButtonPressStartMs = 0;
unsigned long rebootCounterClearStartMs = 0;
bool rebootCounterPendingClear = false;
String topic = "";
uint16_t shortCode = 0;
int pendingHelloSeq = -1;
int wifiStatusSeq = 0;static const uint8_t MAGIC_NUMBER = 0xAA;
static const unsigned long BLE_NOTIFY_GAP_MS = 120;
static const unsigned long WIFI_ACK_TO_STATUS_GAP_MS = 250;
static const unsigned long WIFI_CONNECTED_TO_DONE_GAP_MS = 250;
static const unsigned long DONE_SETTLE_MS = 500;
static const unsigned long WIFI_CONNECT_TIMEOUT_MS = 90000;
static const unsigned long WIFI_RETRY_INTERVAL_MS = 5000;
static const int BLE_NOTIFY_RETRY_COUNT = 3;
static const unsigned long BLE_NOTIFY_RETRY_GAP_MS = 80;
unsigned long lastBleNotifyMs = 0;// 读取 ESP32 芯片 eFuse 中的真实 MAC 地址
// 这里不用 WiFi.macAddress(),避免在某些初始化阶段拿到 00:00:00:00:00:00
String getBaseMacString() {uint64_t chipId = ESP.getEfuseMac();char mac[13];snprintf(mac,sizeof(mac),"%02X%02X%02X%02X%02X%02X",(uint8_t)(chipId >> 40),(uint8_t)(chipId >> 32),(uint8_t)(chipId >> 24),(uint8_t)(chipId >> 16),(uint8_t)(chipId >> 8),(uint8_t)chipId);return String(mac);
}// 安全复制字符串到固定长度 char 数组,避免越界
void safeCopy(char *dest, size_t size, const char *src) {if (size == 0) {return;}if (src == nullptr) {dest[0] = '\0';return;}strncpy(dest, src, size - 1);dest[size - 1] = '\0';
}// 按照你原来 AP 配网的规则:
// 取 MAC 后 6 位,再拼上设备类型,作为 topic / device_id
String macSuffixTopic() {String mac = getBaseMacString();return mac.substring(6) + aptype;
}// 取 MAC 最后 2 个字节,作为广播 short code
uint16_t makeShortCode() {String mac = getBaseMacString();long value = strtol(mac.substring(8).c_str(), nullptr, 16);return (uint16_t)(value & 0xFFFF);
}// 字符串设备类型转成数值,放到广播和 hello 里
uint8_t deviceTypeValue() {return (uint8_t)aptype.toInt();
}// 上电后尽早记录重启次数。
// 这一步要放在 setup() 最前面,避免用户快速断电重启时还没来得及计数。
void updateBootCounterEarly() {memset(&config, 0, sizeof(config));prefs.begin("bemfa", false);config.magic = prefs.getUChar("magic", 0);config.reboot = prefs.getUChar("reboot", 0);if (config.magic == MAGIC_NUMBER) {config.reboot = config.reboot + 1;prefs.putUChar("reboot", config.reboot);Serial.print("Boot count: ");Serial.println(config.reboot);if (config.reboot >= 4) {restoreFactory();}}
}// 读取已保存的配网信息
void loadConfig() {memset(&config, 0, sizeof(config));config.magic = prefs.getUChar("magic", 0);config.reboot = prefs.getUChar("reboot", 0);prefs.getString("ssid", "").toCharArray(config.stassid, sizeof(config.stassid));prefs.getString("psw", "").toCharArray(config.stapsw, sizeof(config.stapsw));prefs.getString("uid", "").toCharArray(config.cuid, sizeof(config.cuid));prefs.getString("topic", "").toCharArray(config.ctopic, sizeof(config.ctopic));configFlag = config.magic != MAGIC_NUMBER;// 不再阻塞 delay(2000)。启动后稳定运行一段时间,再在 loop 里清零。rebootCounterClearStartMs = millis();rebootCounterPendingClear = config.magic == MAGIC_NUMBER;
}// 稳定运行 2 秒后,把重启计数清零。
// 采用非阻塞方式,避免拖慢启动速度。
void handleBootCounterStableClear() {if (!rebootCounterPendingClear) {return;}if (millis() - rebootCounterClearStartMs < 2000) {return;}config.reboot = 0;prefs.putUChar("reboot", 0);rebootCounterPendingClear = false;Serial.println("Boot count reset");
}// 保存配网成功后的配置到 Flash
void saveConfig() {config.reboot = 0;prefs.putUChar("magic", MAGIC_NUMBER);prefs.putUChar("reboot", 0);prefs.putString("ssid", config.stassid);prefs.putString("psw", config.stapsw);prefs.putString("uid", config.cuid);prefs.putString("topic", config.ctopic);config.magic = MAGIC_NUMBER;
}// 恢复出厂:清空所有持久化配置并重启
void restoreFactory() {Serial.println("Restore factory settings");prefs.clear();memset(&config, 0, sizeof(config));config.reboot = 0;config.magic = 0;delay(500);ESP.restart();
}// 检测 BOOT 按键是否长按
// 长按 5 秒后,清空配置并重启,重新进入 BLE 配网模式
void handleResetButton() {int buttonState = digitalRead(RESET_BUTTON_PIN);if (buttonState == LOW) {if (resetButtonPressStartMs == 0) {resetButtonPressStartMs = millis();}if (millis() - resetButtonPressStartMs >= RESET_HOLD_MS) {Serial.println("BOOT button long press detected");restoreFactory();}return;}resetButtonPressStartMs = 0;
}// 通过 BLE Notify 向 App 发送 JSON 字符串。
// 增加最小发送间隔和有限重试,降低手机侧丢包概率。
bool notifyJson(const String &payload) {if (!deviceConnected || txCharacteristic == nullptr || !txSubscribed) {Serial.println("Device -> App notify skipped: not ready");return false;}unsigned long now = millis();if (lastBleNotifyMs != 0) {unsigned long elapsed = now - lastBleNotifyMs;if (elapsed < BLE_NOTIFY_GAP_MS) {delay(BLE_NOTIFY_GAP_MS - elapsed);}}bool notifyOk = false;for (int attempt = 1; attempt <= BLE_NOTIFY_RETRY_COUNT; attempt++) {notifyOk = txCharacteristic->notify((const uint8_t *)payload.c_str(), payload.length());lastBleNotifyMs = millis();Serial.print("Device -> App notify=");Serial.print(notifyOk ? "ok" : "fail");Serial.print(", attempt=");Serial.print(attempt);Serial.print(", len=");Serial.print(payload.length());Serial.print(": ");Serial.println(payload);if (notifyOk) {break;}delay(BLE_NOTIFY_RETRY_GAP_MS);}return notifyOk;
}// 通用响应:用于 finish 等简单响应
void sendResponse(const char *cmd, int seq, int code) {StaticJsonDocument<256> doc;doc["cmd"] = cmd;doc["seq"] = seq;doc["code"] = code;String out;serializeJson(doc, out);notifyJson(out);
}// 通用错误响应
void sendError(int seq, int code, const char *reason, bool retryable) {StaticJsonDocument<256> doc;doc["cmd"] = "error";doc["seq"] = seq;doc["code"] = code;doc["reason"] = reason;doc["retryable"] = retryable;String out;serializeJson(doc, out);notifyJson(out);
}// 状态上报:用于向 App 异步通知 WiFi 连接过程
void sendStatus(int seq, int code, const char *stage, int progress,const char *reason = nullptr, bool retryable = true) {StaticJsonDocument<256> doc;doc["cmd"] = "status";doc["seq"] = seq;doc["code"] = code;doc["stage"] = stage;if (progress >= 0) {doc["progress"] = progress;}if (reason != nullptr) {doc["reason"] = reason;doc["retryable"] = retryable;}if (WiFi.status() == WL_CONNECTED) {doc["ip"] = WiFi.localIP().toString();doc["rssi"] = WiFi.RSSI();}String out;serializeJson(doc, out);notifyJson(out);
}// hello 响应:告诉 App 当前设备是谁、是什么类型、支持什么能力
void sendHello(int seq) {StaticJsonDocument<384> doc;doc["cmd"] = "hello";doc["seq"] = seq;doc["code"] = 0;doc["ver"] = 2;doc["device_id"] = topic;doc["device_type"] = deviceTypeValue();doc["name"] = Name;JsonArray cap = doc.createNestedArray("cap");cap.add("wifi");String out;serializeJson(doc, out);notifyJson(out);
}// info 响应:额外返回产品信息、固件版本、MAC 等
void sendInfo(int seq) {StaticJsonDocument<384> doc;doc["cmd"] = "info";doc["seq"] = seq;doc["code"] = 0;doc["device_id"] = topic;doc["product_id"] = topic;doc["fw"] = verSion;doc["hw"] = "esp32";doc["mac"] = getBaseMacString();String out;serializeJson(doc, out);notifyJson(out);
}// 收到 wifi 命令后,异步开始连接路由器
// 这里不要在 BLE 回调里一直阻塞等待,否则影响 BLE 通信
void connectWifiAsync() {wifiConnectStarted = true;wifiConnectStartMs = millis();wifiLastNotifyMs = 0;wifiLastRetryMs = millis();provisionState = STATE_WIFI_CONNECTING;WiFi.disconnect(true);delay(100);WiFi.mode(WIFI_STA);WiFi.begin(config.stassid, config.stapsw);
}// 参考你原来的 AP 配网示例:
// 设备联网成功后,请求 bemfa 云端接口创建设备主题
bool addTopicToBemfa() {if (WiFi.status() != WL_CONNECTED) {return false;}bemfaHttp.begin(bemfaClient, "http://pro.bemfa.com/vs/web/v1/deviceAddTopic");bemfaHttp.addHeader("Content-Type", "application/json; charset=UTF-8");StaticJsonDocument<256> jsonDoc;jsonDoc["uid"] = config.cuid;jsonDoc["name"] = Name;jsonDoc["topic"] = topic;jsonDoc["type"] = protoType;jsonDoc["room"] = room;jsonDoc["adminID"] = adminID;jsonDoc["wifiConfig"] = 1;jsonDoc["unCreate"] = 1;String jsonString;serializeJson(jsonDoc, jsonString);int httpCode = bemfaHttp.POST(jsonString);String payload = bemfaHttp.getString();bemfaHttp.end();Serial.print("deviceAddTopic httpCode: ");Serial.println(httpCode);Serial.println(payload);if (httpCode != 200) {return false;}StaticJsonDocument<256> doc;DeserializationError error = deserializeJson(doc, payload);if (error) {Serial.print("deserializeJson failed: ");Serial.println(error.c_str());return false;}int code = doc["code"] | -1;int resCode = doc["data"]["code"] | -1;return code == 0 && (resCode == 0 || resCode == 40006);
}// 更新 BLE 广播内容
// 主广播里放厂商数据,扫描响应里放设备名和服务 UUID
// 这样兼容性会比全部塞到同一个广播包里更好
void updateAdvertisementData(uint8_t setupState) {if (advertising == nullptr) {return;}String bleName = "Bemfa_" + String(shortCode, HEX);NimBLEAdvertisementData advData;NimBLEAdvertisementData scanRespData;advData.setFlags(0x06);scanRespData.setName(bleName.c_str());scanRespData.setCompleteServices(NimBLEUUID(SERVICE_UUID));uint8_t manufacturerField[11] = {10,0xFF,0xFA,0xBE,0x02,0x00,deviceTypeValue(),setupState,0x01,(uint8_t)(shortCode & 0xFF),(uint8_t)((shortCode >> 8) & 0xFF),};advData.addData(manufacturerField, sizeof(manufacturerField));advertising->setAdvertisementData(advData);advertising->setScanResponseData(scanRespData);
}// 初始化 BLE 配网服务
// 包含:
// 1. 创建 GATT Server
// 2. 创建 RX / TX 特征
// 3. 注册连接/断开回调
// 4. 注册 App 写入命令回调
// 5. 启动广播
void startBleProvisioning() {shortCode = makeShortCode();String bleName = "Bemfa_" + String(shortCode, HEX);NimBLEDevice::init(bleName.c_str());NimBLEDevice::setMTU(185);bleServer = NimBLEDevice::createServer();class ServerCallbacks : public NimBLEServerCallbacks {void onConnect(NimBLEServer *server, NimBLEConnInfo &connInfo) override {// App 连接成功后,进入 BLE 已连接状态deviceConnected = true;provisionState = STATE_BLE_CONNECTED;Serial.println("BLE connected");}void onDisconnect(NimBLEServer *server, NimBLEConnInfo &connInfo, int reason) override {// 如果配网还没完成,断开后继续广播,等待 App 重新连接deviceConnected = false;Serial.println("BLE disconnected");if (provisionState != STATE_DONE) {delay(200);server->getAdvertising()->start();}}};bleServer->setCallbacks(new ServerCallbacks());NimBLEService *service = bleServer->createService(SERVICE_UUID);txCharacteristic = service->createCharacteristic(TX_UUID,NIMBLE_PROPERTY::NOTIFY);class TxCallbacks : public NimBLECharacteristicCallbacks {void onSubscribe(NimBLECharacteristic *characteristic, NimBLEConnInfo &connInfo, uint16_t subValue) override {txSubscribed = (subValue != 0);Serial.print("TX subscribe state: ");Serial.println(subValue);}};txCharacteristic->setCallbacks(new TxCallbacks());rxCharacteristic = service->createCharacteristic(RX_UUID,NIMBLE_PROPERTY::WRITE);class RxCallbacks : public NimBLECharacteristicCallbacks {void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo &connInfo) override {// App -> Device 的命令都通过 RX 特征写进来std::string value = characteristic->getValue();if (value.empty()) {return;}Serial.print("App -> Device: ");Serial.println(value.c_str());StaticJsonDocument<512> doc;DeserializationError error = deserializeJson(doc, value.c_str());int seq = doc["seq"] | 0;if (error) {sendError(seq, 3002, "invalid_json", false);return;}const char *cmd = doc["cmd"] | "";Serial.print("BLE cmd: ");Serial.println(cmd);if (strcmp(cmd, "hello") == 0) {// hello 表示握手成功,设备进入 ready 状态provisionState = STATE_READY;pendingHelloSeq = seq;if (txSubscribed) {sendHello(seq);pendingHelloSeq = -1;}return;}if (strcmp(cmd, "wifi") == 0) {// 只有 ready 或 failed 状态下,才允许重新下发 WiFiif (provisionState != STATE_READY && provisionState != STATE_FAILED) {sendError(seq, 3001, "invalid_state", true);return;}const char *ssid = doc["ssid"] | "";const char *password = doc["password"] | "";const char *token = doc["token"] | "";if (strlen(ssid) == 0 || strlen(token) == 0) {sendError(seq, 3002, "invalid_json", false);return;}safeCopy(config.stassid, sizeof(config.stassid), ssid);safeCopy(config.stapsw, sizeof(config.stapsw), password);safeCopy(config.cuid, sizeof(config.cuid), token);safeCopy(config.ctopic, sizeof(config.ctopic), topic.c_str());firstWifiConfig = true;provisionState = STATE_WIFI_RECEIVED;wifiStatusSeq = seq + 1;// 先回复 wifi 命令已接收StaticJsonDocument<128> ack;ack["cmd"] = "wifi";ack["seq"] = seq;ack["code"] = 0;ack["accepted"] = true;String out;serializeJson(ack, out);notifyJson(out);delay(WIFI_ACK_TO_STATUS_GAP_MS);sendStatus(seq + 1, 0, "received", 20);connectWifiAsync();return;}if (strcmp(cmd, "finish") == 0) {// finish 表示 App 主动要求结束配网会话sendStatus(seq, 0, "done", 100);sendResponse("finish", seq, 0);provisionState = STATE_DONE;if (advertising != nullptr) {advertising->stop();}return;}sendError(seq, 3003, "unsupported_cmd", false);}};rxCharacteristic->setCallbacks(new RxCallbacks());service->start();advertising = NimBLEDevice::getAdvertising();advertising->addServiceUUID(SERVICE_UUID);advertising->enableScanResponse(true);updateAdvertisementData(1);advertising->start();Serial.println("Started BLE provisioning");
}// 如果设备之前已经配网成功,启动后直接连之前保存的 WiFi
void waitSavedWifiConnect() {WiFi.disconnect(true);delay(100);WiFi.mode(WIFI_STA);WiFi.begin(config.stassid, config.stapsw);while (WiFi.status() != WL_CONNECTED) {handleBootCounterStableClear();delay(500);Serial.print(".");}Serial.println();Serial.print("WiFi connected: ");Serial.println(WiFi.localIP());
}// 上电初始化:
// 1. 初始化串口和按键
// 2. 生成 topic / shortCode
// 3. 读取已保存配置
// 4. 如果未配网则启动 BLE 配网
// 5. 如果已配网则直接联网
void setup() {Serial.begin(115200);updateBootCounterEarly();delay(200);pinMode(RESET_BUTTON_PIN, INPUT_PULLUP);topic = macSuffixTopic();shortCode = makeShortCode();loadConfig();if (strlen(config.ctopic) == 0) {safeCopy(config.ctopic, sizeof(config.ctopic), topic.c_str());} else {topic = String(config.ctopic);}if (configFlag) {startBleProvisioning();} else {waitSavedWifiConnect();Serial.println("Config success");}
}// BLE 配网状态机
// loop 里只调用这个函数,方便你后续继续添加自己的业务逻辑
void handleBleProvisioning() {if (!configFlag) {return;}if (pendingHelloSeq >= 0 && txSubscribed) {sendHello(pendingHelloSeq);pendingHelloSeq = -1;}if (wifiConnectStarted && provisionState == STATE_WIFI_CONNECTING) {unsigned long now = millis();// 每隔几秒给 App 上报一次“正在连接 WiFi”if (now - wifiLastNotifyMs > 3000) {wifiLastNotifyMs = now;sendStatus(wifiStatusSeq, 0, "wifi_connecting", 40);}if (WiFi.status() != WL_CONNECTED &&now - wifiLastRetryMs > WIFI_RETRY_INTERVAL_MS) {wifiLastRetryMs = now;Serial.println("WiFi retry begin");WiFi.disconnect();delay(100);WiFi.begin(config.stassid, config.stapsw);}if (WiFi.status() == WL_CONNECTED) {// WiFi 连接成功wifiConnectStarted = false;provisionState = STATE_WIFI_CONNECTED;sendStatus(wifiStatusSeq, 0, "wifi_connected", 100);delay(WIFI_CONNECTED_TO_DONE_GAP_MS);if (firstWifiConfig) {// 只要设备已经真正连上路由器,就先把配网信息保存下来。// 这样即使后面的云端注册失败、断电或重启,WiFi 配置也不会丢失。saveConfig();// 为兼容当前 App,WiFi 连上后必须明确进入 done。// 云端注册作为附加步骤,不阻塞 done 的上报。sendStatus(wifiStatusSeq, 0, "done", 100);delay(DONE_SETTLE_MS);configFlag = false;firstWifiConfig = false;updateAdvertisementData(0);// 保留云端注册,但不再通过 BLE 继续推送 cloud 阶段,避免干扰 App 对 done/finish 的处理。addTopicToBemfa();}} else if (now - wifiConnectStartMs > WIFI_CONNECT_TIMEOUT_MS) {// WiFi 长时间连不上,返回失败状态provisionState = STATE_FAILED;wifiConnectStarted = false;sendStatus(wifiStatusSeq, 1003, "wifi_failed", 100, "wifi_timeout", true);}}
}// 每秒打印一次当前 WiFi 状态,方便串口观察联网过程
void handleWifiDebugLog() {if (configFlag) {return;}unsigned long now = millis();if (now - wifiDebugLastPrintMs < 1000) {return;}wifiDebugLastPrintMs = now;String ssid = WiFi.SSID();if (ssid.length() == 0) {ssid = "(none)";}if (WiFi.status() == WL_CONNECTED) {Serial.print("WiFi status: connected, SSID: ");Serial.println(ssid);} else {Serial.print("WiFi status: disconnected, SSID: ");Serial.println(ssid);}
}// 主循环尽量保持干净:
// 1. 检测长按恢复出厂
// 2. 跑配网状态机
// 3. 剩下的空间留给你后续自己的业务代码
void loop() {handleBootCounterStableClear();handleResetButton();handleBleProvisioning();handleWifiDebugLog();//调试函数,仅开发测试使用,需要注册掉// 这里写你的其他业务逻辑。delay(20);//调试函数,仅开发测试使用,需要注册掉
}
十五、这份代码怎么用
如果你想直接拿这份代码测试,按下面步骤就行:
- 安装
ESP32开发板支持包 - 安装
NimBLE-Arduino和ArduinoJson - 新建一个 Arduino 工程,把上面的完整代码复制进去
- 烧录到 ESP32 开发板
- 打开串口监视器,波特率
115200 - 用巴法 App 搜索
Bemfa_xxxx设备并开始配网
如果你后续要接巴法云 MQTT,可以在 wifi_connected 成功之后,再把 MQTT 初始化逻辑接进去。
十六、总结
巴法 App 蓝牙配网这套方案,对于 ESP32 开发板来说是比较友好的。它的核心并不在于协议有多复杂,而在于流程统一、状态明确、接入成本低。
你只要把下面几件事做好,基本就能稳定跑起来:
- BLE 广播要规范
- GATT 的
WRITE和NOTIFY要稳定 - JSON 命令格式要统一
- WiFi 联网状态要持续回传
- 配网完成后要及时收尾
如果你正在做巴法云智能硬件,尤其是灯、插座、开关、传感器这类 ESP32 设备,那么蓝牙配网是很值得优先采用的一种方式。
后面如果有需要,我也可以继续整理一版:
- ESP32 Arduino 蓝牙配网完整示例代码
- NimBLE 版本配网最小工程
- 巴法云 MQTT + 蓝牙配网整合版示例
如果你正准备落地到项目里,建议先从最小 BLE 配网版本开始,把链路跑通之后再逐步叠加业务功能。
