1. 为什么选ESP32和BLE做无线控制入门
很多人第一次接触物联网开发,都是从一块ESP32开发板开始的。这块芯片便宜、资料多、自带Wi-Fi和蓝牙,几乎是把“无线通信”这件事的门槛拉到了地板上。但真到自己动手的时候,问题就来了:Wi-Fi方案要配网、要连路由器、要处理IP地址,手机和板子必须在同一个局域网里,稍微换个环境就得重新折腾一遍。对于只想“按个按钮让灯亮”的初学者来说,这套流程太重了。
蓝牙BLE(Bluetooth Low Energy)就是来解决这个问题的。它不需要路由器,不需要配网,手机和ESP32直接点对点通信,功耗还低。你打开手机APP,扫描、连接、发指令,板子收到后执行动作,整个过程几秒钟就能跑通。对于零基础的人来说,这是建立信心最快的一条路。
这篇文章面向的是完全没有蓝牙开发经验、但手上有ESP32开发板、想用手机APP控制硬件的朋友。我会用MicroPython来写ESP32端的代码,因为它语法简单、烧录方便、调试直观,不需要装庞大的IDE,一个串口终端就能看到输出。手机端我会介绍两种方案:一种是现成的通用BLE调试APP,拿来就能用;另一种是用MIT App Inventor自己做一个简易控制界面,适合想进一步定制的读者。
整篇内容会从BLE的基本概念讲起,然后一步步搭建ESP32的BLE服务,定义特征值,处理手机发来的指令,最后用手机实际连接测试。中间会穿插我在调试过程中踩过的坑,比如UUID怎么写、数据格式怎么定、为什么手机连上了却发不出数据,这些细节在官方文档里往往一笔带过,但实际操作中卡住你的恰恰就是它们。
2. BLE的核心概念与ESP32的角色定位
2.1 BLE通信模型:服务、特征值、描述符
BLE的通信结构和我们熟悉的TCP/IP完全不一样。它不是“建立连接后随便发数据”的模式,而是基于一种叫GATT(Generic Attribute Profile)的层次结构。理解这个结构是写好ESP32蓝牙代码的前提。
打个比方,BLE设备就像一个餐厅。餐厅里有多个服务(Service),每个服务代表一类功能,比如“点餐服务”“结账服务”。每个服务下面有多个特征值(Characteristic),特征值才是真正存放数据的地方,相当于菜单上的具体菜品。每个特征值还可以附带描述符(Descriptor),用来补充说明这个特征值的属性,比如单位、格式。
在ESP32上,我们要做的就是:定义一个服务,在服务里定义几个特征值,一个用来接收手机发来的指令(可写),一个用来向手机汇报状态(可读或通知)。手机APP连接后,通过写特征值来发指令,通过读或订阅通知来获取ESP32的状态。
这里有一个关键点:每个服务和特征值都必须有一个UUID(Universally Unique Identifier)。标准UUID是128位的,比如0000FFE0-0000-1000-8000-00805F9B34FB。但BLE协议允许使用16位的短UUID来代表标准中已定义的服务,比如电池服务是0x180F。对于自定义服务,你可以自己生成一个128位UUID,也可以随便用一组16位值,只要手机端和ESP32端一致就行。我在实际项目中通常用0xFFE0作为服务UUID,0xFFE1作为写特征值,0xFFE2作为通知特征值,这套组合在大多数安卓BLE调试APP里都能直接识别。
2.2 ESP32在BLE中的角色:外设与中心设备
BLE通信有两端:外设(Peripheral)和中心设备(Central)。外设负责广播自己的存在,等待被连接;中心设备负责扫描、发起连接。手机通常扮演中心设备,ESP32扮演外设。这个角色分配是固定的,因为手机的蓝牙协议栈对中心设备模式支持最好,而ESP32的MicroPython库也主要面向外设模式。
ESP32作为外设时,它会周期性地发送广播包,包里包含设备名称、服务UUID等信息。手机扫描到之后,根据名称或UUID找到目标设备,发起连接。连接建立后,双方就可以通过GATT读写数据了。整个过程ESP32不需要知道手机的地址,也不需要主动发起任何请求,它只是被动响应。这种模式的好处是ESP32端代码简单,功耗低;缺点是手机必须先扫描再连接,不能反过来。
2.3 为什么用MicroPython而不是Arduino或ESP-IDF
ESP32的开发方式有很多种:Arduino IDE、ESP-IDF、MicroPython、PlatformIO等等。对于零基础入门BLE控制,我推荐MicroPython,原因有三个。
第一,代码量少。MicroPython的bluetooth库把BLE的底层细节封装得很好,创建一个BLE服务只需要十几行代码。相比之下,ESP-IDF里要配置GATT服务器、注册回调函数、处理事件循环,代码量至少翻三倍。
第二,调试方便。MicroPython通过串口REPL交互,你可以随时输入命令查看变量、测试函数,不需要反复编译烧录。对于BLE这种“连上了但不知道数据发没发出去”的场景,能实时打印日志非常重要。
第三,上手门槛低。如果你之前写过Python,MicroPython的语法几乎不需要学习。即使没写过Python,它的可读性也远高于C语言。当然,MicroPython也有缺点,比如运行效率不如C、内存占用较大,但对于控制LED、读取传感器这类简单任务,完全够用。
3. 开发环境搭建与固件烧录
3.1 硬件准备与驱动安装
你需要一块ESP32开发板,市面上常见的ESP32-WROOM-32、ESP32-DevKitC都可以。如果买的是ESP32-S3或ESP32-C3,MicroPython固件要选对应的版本,不能混用。除了开发板,还需要一根支持数据传输的USB线,注意有些线只能充电不能传数据,这种线插上去电脑识别不到串口,换一根就好了。
驱动方面,大多数ESP32开发板用的是CP2102或CH340串口芯片。Windows 10以上系统通常能自动识别CP2102,CH340可能需要手动装驱动。装好之后,在设备管理器里能看到一个COM端口,记住这个端口号,后面烧录和调试都要用。Mac和Linux用户一般不需要额外装驱动,用ls /dev/tty.*或ls /dev/ttyUSB*就能看到设备。
3.2 MicroPython固件下载与烧录
固件下载地址是micropython.org/download/ESP32_GENERIC/,打开后选择最新的稳定版.bin文件。注意要选对型号:ESP32-WROOM和ESP32-DevKitC选ESP32_GENERIC,ESP32-S3选ESP32_GENERIC_S3,ESP32-C3选ESP32_GENERIC_C3。下载下来的是一个几百KB的bin文件。
烧录工具推荐用esptool,它是Python写的命令行工具,安装命令是pip install esptool。烧录前先擦除Flash:esptool.py --port COM3 erase_flash,把COM3换成你的实际端口。然后烧录固件:esptool.py --chip esp32 --port COM3 write_flash -z 0x1000 ESP32_GENERIC-20240602-v1.23.0.bin。这里的0x1000是ESP32的固件起始地址,ESP32-S3和C3也是0x0或0x1000,具体看固件说明。烧录过程中如果报“Failed to connect”,按住开发板上的BOOT键再试一次。
烧录完成后,用串口终端工具连接开发板。Windows上可以用PuTTY或MobaXterm,Mac上直接用screen /dev/tty.usbserial-0001 115200。连接成功后按一下回车,应该能看到>>>提示符,这说明MicroPython已经跑起来了。
3.3 文件上传与代码编辑工具
MicroPython的代码是直接放在ESP32的文件系统里的,你可以通过串口REPL逐行输入,但更高效的方式是用工具上传.py文件。我常用的是ampy和Thonny。ampy是命令行工具,安装pip install adafruit-ampy,然后用ampy --port COM3 put main.py上传文件。Thonny是一个图形化IDE,内置了MicroPython支持,可以直接编辑、运行、保存文件到开发板,对新手更友好。
不管用哪种工具,最终ESP32上电后会自动执行main.py。所以你的BLE控制代码要保存为main.py,这样每次重启都会自动运行。调试阶段可以先保存为ble_test.py,通过REPL手动import ble_test来运行,避免代码有问题导致无法连接。
4. ESP32端BLE服务代码逐行拆解
4.1 导入库与初始化蓝牙
MicroPython的BLE功能在bluetooth模块里,但不同版本的API略有差异。以下代码基于MicroPython v1.23,ESP32通用固件。
import bluetooth import struct from machine import Pin # 初始化BLE ble = bluetooth.BLE() ble.active(True)bluetooth.BLE()创建BLE对象,active(True)打开蓝牙射频。这两行执行后,ESP32的蓝牙就处于可被发现的状态了,但还没有广播任何服务。注意,如果之前已经调用过active(True),再次调用不会报错,但也不会重复初始化。
4.2 定义UUID与创建服务
# 定义UUID SERVICE_UUID = bluetooth.UUID(0xFFE0) WRITE_CHAR_UUID = bluetooth.UUID(0xFFE1) NOTIFY_CHAR_UUID = bluetooth.UUID(0xFFE2) # 定义特征值 write_char = (WRITE_CHAR_UUID, bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE) notify_char = (NOTIFY_CHAR_UUID, bluetooth.FLAG_NOTIFY | bluetooth.FLAG_READ) # 定义服务 service = (SERVICE_UUID, (write_char, notify_char)) # 注册服务 ((write_handle, notify_handle),) = ble.gatts_register_services((service,))这里有几个关键点。bluetooth.UUID(0xFFE0)创建的是16位UUID,MicroPython会自动把它扩展成128位标准格式。FLAG_WRITE表示这个特征值可写,FLAG_WRITE_NO_RESPONSE表示手机发数据后不需要ESP32回复确认,这样传输更快但可靠性略低。FLAG_NOTIFY表示ESP32可以主动向手机推送数据,FLAG_READ表示手机可以主动读取。
gatts_register_services返回的是句柄(handle),句柄是后续读写数据的索引。注意返回值的解包格式:外层是一个元组,里面每个服务对应一个元组,服务里每个特征值对应一个句柄。如果注册了多个服务,解包时要对应好顺序。
4.3 广播设置与连接回调
# 设置广播内容 def adv_encode_name(name): return struct.pack("<B", len(name) + 1) + b"\x09" + name.encode() adv_data = adv_encode_name("ESP32_BLE_CTRL") ble.gap_advertise(100000, adv_data) # 连接回调 def on_ble_event(event, data): if event == 1: # 连接建立 print("手机已连接") elif event == 2: # 连接断开 print("手机已断开") ble.gap_advertise(100000, adv_data) # 重新广播 ble.irq(on_ble_event)广播数据包的格式是BLE协议规定的:第一个字节是长度,第二个字节是数据类型(0x09表示设备名称),后面是名称内容。gap_advertise(100000, adv_data)里的100000是广播间隔,单位是微秒,100000微秒等于100毫秒。这个值越小,手机扫描到越快,但功耗越高。对于插电供电的项目,100毫秒没问题;如果是电池供电,可以调到500毫秒甚至1000毫秒。
连接回调函数on_ble_event会在手机连接或断开时被调用。事件码1是连接,2是断开。断开后必须重新调用gap_advertise,否则ESP32不会再广播,手机就搜不到了。这个坑我踩过好几次,调试的时候手机断开后死活连不上,后来才发现是忘了重新广播。
4.4 接收指令与解析数据
# 主循环 while True: # 读取写特征值的数据 data = ble.gatts_read(write_handle) if data: cmd = data.decode().strip() print("收到指令:", cmd) if cmd == "LED_ON": led.value(1) ble.gatts_notify(0, notify_handle, b"LED is ON") elif cmd == "LED_OFF": led.value(0) ble.gatts_notify(0, notify_handle, b"LED is OFF") else: ble.gatts_notify(0, notify_handle, b"Unknown command") time.sleep_ms(50)gatts_read读取手机写入的数据,返回的是字节串。decode()转成字符串,strip()去掉可能的首尾空白。判断指令后执行动作,然后通过gatts_notify向手机发送回复。gatts_notify的第一个参数是连接ID,单连接场景下填0就行。
这里有一个容易忽略的点:gatts_read默认读取的是特征值当前的值,如果手机没有写入新数据,它会一直返回上一次的值。所以我在代码里加了if data:判断,但更严谨的做法是结合gatts_read的offset参数或者用事件回调来触发读取。不过在简单的控制场景下,轮询方式足够用。
5. 手机端APP选择与连接测试
5.1 通用BLE调试APP推荐
手机端不需要写代码就能测试ESP32的BLE服务。安卓上我常用“nRF Connect”和“BLE Debugger”,iOS上可以用“LightBlue”。这些APP的功能都差不多:扫描设备、显示服务和特征值、读写数据、订阅通知。
以nRF Connect为例,打开后点击“SCAN”,找到名为“ESP32_BLE_CTRL”的设备,点击“CONNECT”。连接后展开服务列表,找到UUID为0xFFE0的服务,里面有两个特征值:0xFFE1可写,0xFFE2可读和通知。点击0xFFE1旁边的上传图标,输入“LED_ON”,选择UTF-8格式,点击发送。如果ESP32代码正确,你应该能看到串口打印“收到指令: LED_ON”,同时板子上的LED亮起。然后点击0xFFE2旁边的订阅图标,ESP32发送的通知就会实时显示在APP上。
5.2 用MIT App Inventor自制控制APP
通用调试APP适合测试,但如果你想做一个带按钮的专属控制界面,可以用MIT App Inventor。它是一个网页版的图形化编程工具,拖拽组件就能生成安卓APP。你需要用到“BluetoothLE”组件,设置好服务UUID和特征值UUID,然后在按钮点击事件里调用“WriteCharacteristic”发送指令。
具体步骤是:新建项目,拖入一个“ListPicker”用于扫描设备,一个“Button”用于发送开灯指令,一个“Label”显示状态。在逻辑视图里,用“BluetoothLE1.Scan”扫描设备,用“BluetoothLE1.Connect”连接选中的设备。按钮点击时调用“BluetoothLE1.WriteCharacteristic”,传入服务UUID、特征值UUID和要发送的字符串。MIT App Inventor的BLE组件对UUID格式有要求,必须填完整的128位格式,比如0000FFE0-0000-1000-8000-00805F9B34FB,不能只填FFE0。
5.3 连接测试与数据收发验证
测试流程建议分三步走。第一步,只烧录广播代码,不注册服务,用手机APP扫描,确认能看到设备名称。这一步验证蓝牙射频和广播配置是否正确。第二步,注册服务但不处理数据,用APP连接后查看服务列表,确认UUID和特征值属性显示正确。第三步,加入数据处理逻辑,发送指令并观察串口输出和硬件反应。
每一步都要确认后再进行下一步,不要一次性把所有代码写完再调试。BLE的问题往往出在细节上,比如UUID大小写、特征值属性标志、广播数据格式,分开验证能快速定位问题所在。
6. 常见问题排查与避坑经验
6.1 手机搜不到ESP32的广播
这是最常见的问题,原因通常有三个。第一,广播没有启动。检查代码里是否调用了gap_advertise,以及是否在断开回调里重新调用了它。第二,广播数据格式错误。adv_encode_name函数里的长度字节必须是名称长度加1,类型字节必须是0x09,这两个值错了手机就解析不出设备名。第三,蓝牙没有激活。确认ble.active(True)已经执行,并且没有其他代码把它关掉。
还有一个隐蔽的原因:某些安卓手机在系统设置里关闭了“蓝牙扫描”权限,或者APP没有获取定位权限。安卓从6.0开始,BLE扫描需要定位权限,因为蓝牙信标可以用来推断位置。如果APP没有这个权限,扫描会返回空列表。去手机设置里给APP打开定位权限就好了。
6.2 连接后发数据没反应
手机显示已连接,但发送指令后ESP32没有任何反应。先检查串口有没有打印“收到指令”。如果没有打印,说明gatts_read没有读到数据。可能的原因是特征值的写权限标志不对。如果你只写了FLAG_WRITE,手机必须用“带响应写”的方式发送;如果你写了FLAG_WRITE_NO_RESPONSE,手机可以用“无响应写”。有些APP默认用无响应写,如果你的特征值只支持带响应写,数据就发不进去。解决办法是两个标志都加上。
如果串口打印了指令但硬件没动作,检查指令字符串是否匹配。手机APP发送时可能带了换行符或空格,strip()能去掉首尾空白,但如果中间有不可见字符就不行了。可以在串口里打印repr(data)看看实际收到的字节是什么。
6.3 通知功能不工作
ESP32调用gatts_notify后手机没收到通知,通常是手机端没有订阅。在nRF Connect里,需要点击特征值旁边的三个箭头图标来启用通知。在自制APP里,需要调用BluetoothLE1.RegisterForBytes或类似方法来订阅。另外,通知特征值必须设置FLAG_NOTIFY,只设置FLAG_READ是不够的。
还有一个细节:gatts_notify的第二个参数是特征值句柄,不是UUID。很多人会把UUID传进去,结果通知发到了错误的地方。句柄是gatts_register_services返回的,要保存好。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 手机搜不到设备 | 广播未启动或格式错误 | 检查gap_advertise调用和广播数据长度字节 |
| 连接后立即断开 | 未重新广播或供电不足 | 在断开回调里重新广播,换USB口或电源 |
| 发数据无反应 | 特征值写权限标志不对 | 同时加FLAG_WRITE和FLAG_WRITE_NO_RESPONSE |
| 通知收不到 | 手机未订阅或句柄错误 | 在APP里启用通知,确认gatts_notify用句柄 |
| 数据乱码 | 编码格式不一致 | 统一用UTF-8,打印repr(data)检查 |
| 多次连接后失败 | 连接ID未更新 | 单连接场景用0,多连接需保存conn_handle |
7. 从点亮LED到项目扩展的实操建议
7.1 指令协议设计:从字符串到结构化数据
用纯字符串指令(如“LED_ON”)做demo没问题,但项目稍微复杂一点就会乱。比如你要控制RGB灯的颜色,字符串就得写成“RGB_255_0_0”,解析起来很麻烦。更好的做法是用JSON或简单的二进制协议。JSON可读性好,MicroPython有json库,解析几行代码就行。二进制协议效率高,适合数据量大的场景,但调试不如JSON直观。
我通常的做法是:控制指令用JSON,状态上报用JSON,这样手机端和ESP32端都能用同一套解析逻辑。比如手机发{"cmd":"led","value":1},ESP32解析后执行,然后回复{"status":"ok","led":1}。JSON的缺点是占空间,BLE单次传输默认最多20字节,JSON很容易超。可以在手机端开启“长写”或分片传输,或者把JSON压缩成短键名。
7.2 低功耗优化:广播间隔与休眠策略
如果项目是电池供电的,功耗就是核心指标。BLE的功耗主要来自广播和连接保持。广播间隔从100毫秒调到1000毫秒,平均电流能降一半以上。连接间隔也可以在手机端设置,安卓APP通常有“Connection Priority”选项,选“Low Power”会增大连接间隔,降低功耗。
ESP32本身也支持轻睡眠和深睡眠。轻睡眠下蓝牙可以保持连接,但CPU暂停,唤醒后继续执行。深睡眠下蓝牙断开,唤醒后需要重新广播。对于需要实时响应的控制场景,轻睡眠比较合适;对于每隔几分钟上报一次数据的传感器,深睡眠更省电。MicroPython对睡眠的支持不如ESP-IDF完善,如果功耗要求苛刻,可能需要换回C语言开发。
7.3 安全考虑:配对与加密
BLE通信默认是不加密的,任何在广播范围内的设备都能连接和读写。对于控制类项目,这意味着别人可以随便开关你的灯。BLE提供了配对和加密机制,可以在连接时要求输入密码或确认配对。MicroPython的bluetooth库支持gap_set_security相关API,但文档比较少,实际使用中容易出问题。
一个折中方案是在应用层加简单的校验,比如手机发送的指令里带一个预共享的token,ESP32验证token后才执行。这种方式不能防止窃听,但能防止误操作。如果项目涉及门锁、电机等安全相关的控制,建议还是用ESP-IDF的BLE安全功能,或者改用Wi-Fi加TLS的方案。
7.4 项目扩展方向:传感器上报与多设备联动
BLE控制LED只是起点,同样的框架可以扩展到很多场景。比如接一个DHT11温湿度传感器,ESP32定时读取数据,通过通知特征值推送给手机,手机APP显示实时曲线。或者接一个继电器模块,用手机控制家电开关。再进一步,可以用ESP32同时作为BLE外设和Wi-Fi客户端,手机通过BLE发指令,ESP32通过Wi-Fi转发到其他设备,实现多协议联动。
我在实际项目中做过一个案例:ESP32通过BLE接收手机发来的颜色值,控制WS2812灯带的颜色,同时通过Wi-Fi把颜色数据上报到本地服务器做记录。手机端用MIT App Inventor做了一个色轮界面,滑动选色,实时发送。整个系统跑下来很稳定,延迟在100毫秒以内,成本不到50块钱。
最后分享一个小技巧:调试BLE的时候,串口打印一定要详细。每次连接、断开、收到数据、发送通知,都打印一行日志。这些日志在正常运行时看起来很多余,但出问题的时候,它们能帮你快速定位是手机没发、ESP32没收到、还是收到了但处理错了。我习惯在关键位置加print,调试完再注释掉,比用调试器打断点方便得多。