跳到主要内容

蓝牙 (Bluetooth)

ESP32 系列芯片内置了蓝牙功能,适用于智能穿戴、无线传感和设备间近场通信。蓝牙技术分为两种主要类型:

  • 经典蓝牙 (Bluetooth Classic):为持续性、高吞吐量的数据传输设计,常见于无线音频设备。
  • 低功耗蓝牙 (Bluetooth Low Energy, BLE):专为低功耗、间歇性、小数据包通信而优化,是物联网(IoT)应用的主流选择,如智能手环、无线传感器。

ESP32 产品概览

ESP32 芯片的蓝牙支持情况有所不同:经典的 ESP32 芯片同时支持经典蓝牙和 BLE;而后续的新型号则专注于支持 BLE,以优化成本和功耗(具体支持情况请查看:ESP32 产品概览)。在物联网、可穿戴设备等领域,BLE 因其低功耗和高兼容性成为首选。

本教程聚焦于低功耗蓝牙(BLE)技术的应用。

1. BLE 基础概念

BLE 通信基于以下两个核心:

  • GAP(Generic Access Profile) - 通用访问规范:描述设备广播、发现、连接的规则。例如,ESP32 广播自身存在,手机进行扫描和连接。
  • GATT(Generic Attribute Profile)- 通用属性规范:定义 BLE 设备之间的数据结构和通讯方式。GATT 层由“服务(Service)”和“特征(Characteristic)”组成,每个数据项都有唯一的 UUID。

简而言之,GAP 负责“让设备找到彼此并连接”,连接成功后,GATT 接管并定义“双方如何规范地交换数据”。

1.1 GAP(Generic Access Profile)

GAP 负责管理设备的连接和广播,并定义了设备在蓝牙通信中的角色。

GAP 定义了两种主要角色:

  • 外围设备 (Peripheral): 通常是拥有数据的设备,如传感器。它通过广播(Advertising)来宣告自身存在,等待被连接。在示例中,ESP32 主要扮演此角色。
  • 中央设备 (Central): 通常是发起连接的设备,如智能手机或电脑。它通过扫描(Scanning)来发现外围设备,并发起连接。

GAP 关系

GAP 通过以下过程实现设备间的交互:

  • 广播 (Advertising): 外围设备周期性地发送广播包,其中包含设备名称、服务 UUID 等信息,以便中央设备发现。
  • 扫描 (Scanning): 中央设备监听广播信道,接收并解析来自外围设备的广播包。
  • 建立连接 (Connecting): 中央设备向其选定的外围设备发起连接请求,一旦外围设备接受,两者便建立起一对一的连接。

1.2 GATT(Generic Attribute Profile)

GATT (Generic Attribute Profile) 在设备建立连接后生效,它定义了数据交换的框架和格式。GATT 基于一种客户端-服务器(Client-Server)架构。这两个角色通常与 GAP 角色直接对应:

  • GATT 服务器 (Server): 即拥有数据的设备(通常对应 GAP 的外围设备),它存储并提供数据。
  • GATT 客户端 (Client): 即访问数据的设备(通常对应 GAP 的中央设备),它向服务器发送读/写请求。

GATT 中的数据以一种标准化的层级结构组织:

GATT 层次

  • 服务 (Service) 一个服务是多个相关“特征”的逻辑集合,代表设备的一项功能。每个服务由一个唯一的 UUID 标识。例如,“电池服务 (Battery Service)”可能包含一个“电量水平 (Battery Level)”特征。

  • 特征 (Characteristic) 特征是数据交换的基本单元,封装了一个具体的数据值。一个完整的特征包含:

    • 值 (Value): 存储的实际数据。
    • 属性 (Properties): 定义客户端可对“值”执行的操作,常见的有:
      • Read: 允许客户端读取值。
      • Write: 允许客户端写入值。
      • Notify: 允许服务器在值改变时,主动将新值发送给客户端。
      • Indicate: 与 Notify 类似,但需要客户端确认收到。
    • 声明 (Declaration): 包含特征的属性、UUID 和在服务中的位置。
  • 描述符 (Descriptor) 描述符是可选的,它为特征提供附加的元数据(metadata)。例如,它可以用来提供一个人类可读的描述(如 "Temperature Measurement")、指明值的单位(如 "Celsius")或定义一个有效的数值范围。

  • UUID (Universally Unique Identifier) UUID 是用于唯一标识服务、特征和描述符的 128 位数字。为了方便,蓝牙技术联盟 (SIG) 为通用功能预定义了一套官方的短 UUID(通常为 16 位),例如 0x180F 代表电池服务。当开发自定义应用时,应使用随机生成的完整 128 位 UUID,以确保全局唯一性。所有已分配的标准 UUID 可在 SIG 官网 查询。

2. 准备工作:安装 aioble

MicroPython 提供了 aioble 库,该库基于 asyncio(异步 I/O)构建,简化了 BLE 开发。相比底层的 bluetooth 模块,aioble 提供了更高级的 API。

在使用之前,需将 aioble 库安装到 ESP32 开发板。

  1. 确保 ESP32 已连接到 WiFi:安装库需要联网。

    import network
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect("Maker", "12345678") # 替换为实际 SSID 和 密码
    wlan.isconnected()
    wlan.ifconfig()
  2. 使用 mip 安装:在 Thonny 的 REPL 中运行以下命令:

    import mip
    mip.install("aioble")

    mip 会将库安装到 MicroPython 设备的 /lib 目录下。在文件视图中默认不可见,可以通过在文件视图右键点击“显示隐藏文件”查看。

    在 Thonny Shell 中安装 aioble

安装完成后,就可以在代码中 import aioble 了。

3. 示例 1:通过 BLE 发送数据(外围设备)

本示例将 ESP32 配置为外围设备,读取电位器的模拟值,并通过一个 BLE 特征 (Characteristic) 将其发布。可以使用手机 App (如 LightBlue) 作为中央设备连接 ESP32,并读取该特征的值。

示例1-BLE通信架构图

3.1 搭建电路

需要使用的器件有:

  • 电位器 * 1
  • 面包板 * 1
  • 导线
  • ESP32 开发板

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图

3.2 代码

提示

为确保 BLE 设备唯一性,建议使用自定义 UUID。可用在线工具(如 Online UUID Generator)生成新 UUID。

import aioble
import bluetooth
import machine
import uasyncio as asyncio

# 定义 UUID,建议使用随机生成的 UUID
_SERVICE_UUID = bluetooth.UUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
_POT_CHAR_UUID = bluetooth.UUID("1b9a473a-4493-4536-8b2b-9d4133488256")

# 广播间隔
_ADV_INTERVAL_US = 250_000

# 配置外围设备 (GATT Server)
# 注册服务和特征
# read=True: 允许客户端读取
# notify=True: 允许服务器主动通知
pot_service = aioble.Service(_SERVICE_UUID)
pot_characteristic = aioble.Characteristic(
pot_service, _POT_CHAR_UUID, read=True, notify=True
)
aioble.register_services(pot_service)

# 硬件初始化
pot = machine.ADC(machine.Pin(7))

# 任务:读取传感器并更新特征值
async def sensor_task():
print("Sensor task started")
last_val = -1

while True:
# 读取电位器值 (0-4095)
val = pot.read()

# 只有当值发生显著变化时才更新 (简单的去抖/过滤)
if abs(val - last_val) > 10:
last_val = val
print(f"Potentiometer value: {val}")

# 写入特征值
# 注意:BLE 数据传输通常使用字节串,需将整数转换为字节。
# 此处将 12 位整数 (0-4095) 转换为 2 字节小端序数据
encoded_val = val.to_bytes(2, "little")
pot_characteristic.write(encoded_val)

# 向已连接的客户端发送通知
# notify 仅在有客户端连接并订阅时有效,aioble 会自动处理此类检查
pot_characteristic.write(encoded_val, send_update=True) # connection=None 表示通知所有已订阅的客户端

# 异步延时,让出 CPU 给其他任务
await asyncio.sleep_ms(100)

# 处理蓝牙广播与连接
async def peripheral_task():
print("Advertising task started")
while True:
# 开始广播
# name: 设备名称
# services: 广播中包含的服务 UUID 列表,便于客户端扫描发现
async with await aioble.advertise(
_ADV_INTERVAL_US, # 广播间隔 (微秒)
name="ESP32_Potentiometer",
services=[_SERVICE_UUID],
) as connection:
print("Connection from", connection.device)
# 当有设备连接时,代码将在此阻塞,直至连接断开
# 连接期间,sensor_task 仍在后台运行并更新数据
await connection.disconnected()
print("Disconnected")

# 连接断开后,循环继续,重新开始广播

# 主程序入口
async def main():
# 并发运行两个任务
t1 = asyncio.create_task(sensor_task())
t2 = asyncio.create_task(peripheral_task())
await asyncio.gather(t1, t2)

# 启动 asyncio 事件循环
asyncio.run(main())

3.2.1 代码解析

本示例利用 aioble 库和 uasyncio 协程,实现 BLE 事件(如连接、断开)处理与传感器读取的“并发”执行。

  1. 定义 UUID: 使用 bluetooth.UUID 对象来定义服务和特征的唯一标识符。

  2. 注册服务和特征 (aioble.Service, aioble.Characteristic):

    • 首先创建 Service 对象。
    • 然后在该服务下创建 Characteristic 对象。
    • read=Truenotify=True 定义了该特征的权限。
    • 最后调用 aioble.register_services() 将它们注册到 BLE 协议栈中。
  3. sensor_task (传感器任务):

    • 这是一个无限循环任务,负责周期性读取电位器。
    • pot_characteristic.write(encoded_val, send_update=True): 更新特征的本地值,并向所有已订阅的客户端发送通知(Notify)。
      • encoded_val: BLE 数据传输使用字节串,因此需使用 to_bytes 将整数转换为字节串。
      • send_update=True: 指示 aioble 自动处理通知发送。如果客户端已订阅,它将收到新数据;如果没有订阅,则仅更新本地值。
    • await asyncio.sleep_ms(100): 异步延时,将 CPU 控制权交还给调度器,允许其他任务(如广播或底层 BLE 协议栈)运行。
  4. peripheral_task (广播任务):

    • aioble.advertise(...): 启动 BLE 广播。
      • name: 设置广播包中的设备名称。
      • services: 列出支持的服务 UUID,便于客户端扫描发现。
    • async with await ... as connection: 使用异步上下文管理器处理连接生命周期。
      • 当有中央设备连接时,代码进入 with 块,并获得 connection 对象。
      • 此时广播会自动停止(除非配置为多连接)。
    • await connection.disconnected(): 异步等待连接断开。在此期间,任务处于挂起状态,直到连接断开事件发生。
    • 循环机制:当连接断开后,程序退出 with 块,随即进入下一次循环,重新启动广播等待新的连接。
  5. asyncio.run(main()): 启动 asyncio 事件循环,调度并运行定义好的任务。

3.2.2 运行结果

提示

本示例需要使用蓝牙调试工具,如 LightBlue。iOS 用户可在 Apple Store 下载,安卓用户可在应用商店搜索 LightBlue 下载。

打开 LightBlue,执行以下操作:

首先,搜索“ESP32”,找到“ESP32_Potentiometer”设备并点击“Connect”连接。在设备详情页中,找到特征,可以看到已开启可读和可订阅功能,然后点击进入。点击右上角的“HEX”设置数据类型,以便后续查看数据。

示例1 LightBlue 操作步骤1

设置“Byte Limit”为 2,并选择“2 Byte Unsigned Integer”,然后保存。保存后返回特征详情页,点击“Read”读取数据。转动电位器,再次读取即可看到变化。也可以点击“Subscribe”订阅数据,当转动电位器时数值会自动刷新。

示例1 LightBlue 操作步骤2

4. 示例 2:从 BLE 接收数据(外围设备)

本示例将 ESP32 配置为外围设备,创建一个可写的 BLE 特征。手机 App (如 LightBlue) 可向该特征写入特定值(如 0 或 1),以控制连接在 ESP32 上的 LED 的亮灭。

示例2-BLE通信架构图

4.1 搭建电路

需要使用的器件有:

  • LED * 1
  • 330Ω 电阻 * 1
  • 面包板 * 1
  • 导线
  • ESP32 开发板

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图

4.2 代码

import aioble
import bluetooth
import machine
import uasyncio as asyncio

# 定义 UUID ,建议使用随机生成的 UUID
_SERVICE_UUID = bluetooth.UUID("48407a44-6e13-4d28-a559-210de862bc29")
_LED_CHAR_UUID = bluetooth.UUID("539ca2ac-09e5-49be-90da-3b157549eac3")

# 广播间隔
_ADV_INTERVAL_US = 250_000

# 配置外围设备
# read=True, write=True: 允许读写
# capture=True: 允许 aioble 捕获写入事件,以便在代码中处理
led_service = aioble.Service(_SERVICE_UUID)
led_characteristic = aioble.Characteristic(
led_service, _LED_CHAR_UUID, read=True, write=True, capture=True
)
aioble.register_services(led_service)

# 硬件初始化
led = machine.Pin(7, machine.Pin.OUT)
led.value(0) # 默认关闭

# 任务:处理写入请求
async def led_task():
print("LED task started")
while True:
# 等待客户端写入数据
# written() 返回一个上下文管理器,当有写入发生时,会返回 connection 和 value
connection, value = await led_characteristic.written()

print(f"Received: {value} from {connection.device}")

if value:
# 解析命令 (假设只发送 1 个字节)
command = value[0]

if command == 1:
print("Turning LED ON")
led.value(1)
elif command == 0:
print("Turning LED OFF")
led.value(0)
else:
print(f"Unknown command: {command}")

# 更新特征的值,以便客户端读取时能获取最新状态
led_characteristic.write(value)

# 任务:处理广播(同示例 1)
async def peripheral_task():
print("Advertising task started")
while True:
async with await aioble.advertise(
_ADV_INTERVAL_US,
name="ESP32-LED",
services=[_SERVICE_UUID],
) as connection:
print("Connection from", connection.device)
await connection.disconnected()
print("Disconnected")

# 主程序入口
async def main():
t1 = asyncio.create_task(led_task())
t2 = asyncio.create_task(peripheral_task())
await asyncio.gather(t1, t2)

asyncio.run(main())

4.2.1 代码解析

  1. capture=True: 在初始化 Characteristic 时,设置 capture=True 至关重要。该参数指示 aioble 将写入请求传递给应用层处理,而不是由底层协议栈自动确认。这使得程序能够捕获写入事件并执行相应的逻辑(如控制 LED)。

  2. await led_characteristic.written(): 这是一个异步等待方法,用于监听写入事件。

    • 当客户端写入数据时,该方法返回一个元组 (connection, value)
    • connection: 发起写入请求的客户端连接对象。
    • value: 客户端写入的原始数据(字节串)。
  3. 数据解析与控制: 程序获取 value 后,提取其中的命令字节,根据命令值(0 或 1)控制 LED 的 GPIO 电平。

  4. 状态同步 (led_characteristic.write): 在处理完硬件操作后,调用 write(value) 更新特征的本地缓存值。这确保了如果客户端随后读取该特征,能够获得与硬件状态一致的最新值。

4.2.2 运行结果

提示

本示例需要使用蓝牙调试工具,如 LightBlue。iOS 用户可在 Apple Store 下载,安卓用户可在应用商店搜索 LightBlue 下载。

打开 LightBlue,按照下面步骤操作:

首先,搜索“ESP32”,找到“ESP32_LED_Control”设备并点击“Connect”连接。在设备详情页中,找到特征,可以看到已开启可读和可写功能,然后点击进入。点击右上角的“HEX”设置数据类型,以便后续查看数据。

示例2 LightBlue 操作步骤1

设置“Byte Limit”为 1,并选择“1 Byte Unsigned Integer”,然后保存。保存后返回特征详情页,点击“Read”读取数据。默认值为 0,此时 LED 为熄灭状态。点击“Write new value”, 写入数值 1,LED 随即亮起。

示例2 LightBlue 操作步骤2

5. 示例 3:ESP32 间 BLE 通信

通过 BLE,用一个 ESP32 连接的电位器,控制另一个 ESP32 连接的 LED。

示例3-BLE通信架构图

5.1 搭建电路

需要使用的器件有:

  • LED * 1
  • 330Ω 电阻 * 1
  • 电位器 * 1
  • 面包板 * 2
  • 导线
  • ESP32 开发板 * 2

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图

5.2 代码

5.2.1 ESP32 开发板 A 代码 (外围设备 - LED 端)

这段代码与示例 2 非常相似,只是 UUID 不同。它作为服务器,等待客户端连接并写入亮度值。

import aioble
import bluetooth
import machine
import uasyncio as asyncio
import struct

# 定义 UUID
_SERVICE_UUID = bluetooth.UUID("458063a1-02bf-4664-857e-16c1030be066")
_BRIGHTNESS_CHAR_UUID = bluetooth.UUID("a5209632-66a9-411d-9353-9be5507790fa")

# 广播间隔
_ADV_INTERVAL_US = 250_000

# 配置外围设备
# capture=True: 允许 aioble 捕获写入事件
led_service = aioble.Service(_SERVICE_UUID)
led_characteristic = aioble.Characteristic(
led_service, _BRIGHTNESS_CHAR_UUID, read=True, write=True, capture=True
)
aioble.register_services(led_service)

# 硬件初始化
led = machine.PWM(machine.Pin(7))
led.freq(1000)
led.duty_u16(0)

# 任务:处理写入请求
async def led_task():
print("LED task started")
while True:
connection, value = await led_characteristic.written()

if value:
# 接收到的值是 0-65535 (2个字节, Little Endian)
try:
duty_u16 = struct.unpack("<H", value)[0]
print(f"Received duty: {duty_u16}")

# 直接设置 PWM 占空比
led.duty_u16(duty_u16)

# 更新特征值
led_characteristic.write(value)
except:
pass

# 任务:处理广播
async def peripheral_task():
print("Advertising task started")
while True:
async with await aioble.advertise(
_ADV_INTERVAL_US,
name="ESP32-LED",
services=[_SERVICE_UUID],
) as connection:
print("Connection from", connection.device)
await connection.disconnected()
print("Disconnected")

# 主程序入口
async def main():
t1 = asyncio.create_task(led_task())
t2 = asyncio.create_task(peripheral_task())
await asyncio.gather(t1, t2)

asyncio.run(main())

5.2.2 ESP32 开发板 B 代码 (中央设备 - 电位器端)

这段代码展示了如何使用 aioble 作为中央设备(Client)。它需要扫描、连接、发现服务,然后写入数据。

import aioble
import bluetooth
import machine
import uasyncio as asyncio
import struct

# 定义目标 UUID
_SERVICE_UUID = bluetooth.UUID("458063a1-02bf-4664-857e-16c1030be066")
_BRIGHTNESS_CHAR_UUID = bluetooth.UUID("a5209632-66a9-411d-9353-9be5507790fa")

# 硬件初始化
pot = machine.ADC(machine.Pin(7))

# 辅助函数:查找并连接设备
async def find_device():
print(f"Scanning for UUID: {_SERVICE_UUID} ...")
# 扫描 5 秒
async with aioble.scan(5000, interval_us=30000, window_us=30000, active=True) as scanner:
async for result in scanner:
# 检查服务 UUID
if _SERVICE_UUID in result.services():
device_name = result.name() or "Unknown"
print(f"Found Target Device: {device_name}")
return result.device
return None

# 主任务
async def central_task():
print("Central task started")

while True:
device = await find_device()
if not device:
print("Device not found, retrying...")
await asyncio.sleep_ms(1000)
continue

try:
print(f"Connecting to device...")
connection = await device.connect(timeout_ms=5000)
except asyncio.TimeoutError:
print("Connection timeout")
continue

async with connection:
print("Connected")

try:
# 发现服务
print("Discovering services...")
service = await connection.service(_SERVICE_UUID)
if not service:
print("Service not found")
continue

# 发现特征
print("Discovering characteristics...")
char = await service.characteristic(_BRIGHTNESS_CHAR_UUID)
if not char:
print("Characteristic not found")
continue

print("Ready to send data")
last_val = -1

while True:
# 读取电位器 (0-65535)
val = pot.read_u16()

# 只有变化超过一定阈值才发送,避免抖动
if abs(val - last_val) > 1000:
last_val = val
print(f"Sending duty: {val}")

# 写入数据 (2 字节,小端序)
await char.write(struct.pack("<H", val), response=False)

await asyncio.sleep_ms(100)

except Exception as e:
print(f"Error: {e}")

print("Disconnected")
# 循环回到开始,重新扫描连接

# 主程序入口
asyncio.run(central_task())

5.2.3 代码解析

外围设备 (A - LED 端)

代码逻辑与示例 2 类似,主要区别在于数据处理方式:

  • PWM 控制: 使用 machine.PWM 替代数字输出,以实现 LED 亮度调节。
  • 数据解包: 接收到的数据为 2 字节的小端序(Little Endian)字节串。使用 struct.unpack("<H", value) 将其还原为 Python 的整数(u16),直接对应 PWM 的占空比参数。
中央设备 (B - 电位器端)

该部分展示了 BLE 中央设备(Client)的典型工作流程:

  1. 设备扫描 (aioble.scan):

    • 启动扫描并获取 scanner 对象。
    • 使用 async for 异步迭代扫描结果。
    • 通过检查 result.services() 是否包含目标服务的 UUID,筛选出特定的外围设备。
  2. 建立连接 (device.connect):

    • 锁定目标设备后,调用 device.connect() 发起连接请求。
    • 使用 async with connection: 上下文管理器维护连接。这确保了在任务结束或发生异常时,连接能够被正确关闭,释放资源。
  3. 服务与特征发现:

    • 服务发现: 连接成功后,首先通过 connection.service(_SERVICE_UUID) 获取远程服务对象。
    • 特征发现: 在服务对象的基础上,通过 service.characteristic(_BRIGHTNESS_CHAR_UUID) 获取远程特征对象。
    • 这一步是必不可少的,只有获取了特征对象,才能对其进行读写操作。
  4. 数据发送 (char.write):

    • 数据打包: 使用 struct.pack("<H", val) 将电位器的整数值(u16)打包为 2 字节的小端序字节串,符合外围设备的解析格式。
    • 无响应写入: 调用 char.write(..., response=False) 发送数据。设置 response=False(即 Write Without Response)可以避免等待服务器的确认包,显著提高数据传输的吞吐量,适合实时性要求较高的传感器数据流。

5.2.4 运行结果

  1. 分别将代码上传到两块 ESP32 开发板。
  2. 开发板 A (LED) 会开始广播。
  3. 开发板 B (电位器) 会扫描到 A,并自动建立连接。
  4. 转动 B 上的电位器,A 上的 LED 亮度会随之平滑变化。
  5. 如果断开 A 的电源,B 会检测到断开并重新开始扫描;当 A 重新上电后,连接会自动恢复。

6. 相关链接