蓝牙学习3(简易蓝牙控制)
1 开发环境
硬件平台用的ESP32C3 mini + nRF Connect(小米手机)

这次还是先用micopython,主要是来的快,方便对整体概念简建立一些感性认识。
搭建micopython环境,硬件连接这里就不多说了。。。
2 蓝牙广播
第一个实验是蓝牙广播,也就是发现蓝牙。
2.1 代码
import bluetooth
# 1. 复位蓝牙,避免残留状态
ble = bluetooth.BLE()
ble.active(False)
import utime
utime.sleep_ms(100)
ble.active(True)
# 设备名称
dev_name = "C3-DEV"
# 标准BLE广播帧:Flags+设备名(固定格式,手机必扫到)
# 0x02,0x01,0x06 = BLE通用发现标志
adv_data = bytearray([0x02,0x01,0x06, len(dev_name)+1, 0x09]) + dev_name.encode()
# 开启广播:间隔100000us=100ms,可连接模式
ble.gap_advertise(100000, adv_data=adv_data, connectable=True)
print("BLE广播开启,设备名:C3-DEV")
2.2 代码分析
代码还是很简单,只要一运行就能看到效果。我用的软件是Nordic的nRF connect。起码比原始自带的蓝牙搜索软件强一些。整个代码稍微复杂一点的就是adv data。代码如下:
adv_data = bytearray([0x02,0x01,0x06, len(dev_name)+1, 0x09]) + dev_name.encode()
BLE 广播由多条「AD 结构」拼接而成,每条 AD 格式 = [长度 (1B) + 类型 (1B) + 数据 (N 字节)]
[0x02, 0x01, 0x06] → 第一条 AD:BLE Flags 标志位。0x02:本条 AD 总长度。0x01:AD 类型 = Flags,后面的0x06是一些蓝牙属性。
[len(dev_name)+1, 0x09] + dev_name.encode() → 第二条 AD:完整蓝牙设备名。09表示Complete Local Name 完整设备名称。
看来本质就是类似TLV 编码(Type-Length-Value),很久很久之前做项目就是这个,没想到现在还在用这边编码。

在发广播(Advertising)的时候,运行MicroPython的芯片(从机)是在向空气中盲目地发报文。为了保证低功耗、高速度,蓝牙官方规范严格限制了一个传统广播包的最大长度只有 31 个字节(31 Bytes)。
在这的 31 个字节里,除掉蓝牙固定的报文头、Mac地址等物理开销,留给用户自定义的有效载荷(Payload)通常只剩 20多字节。
所以,设备在广播时,通常只塞入最核心的信息:
Flags(标志位):1字节,声明自己是“通用可发现”的。
Local Name(设备名称):比如叫
USV_GATTS_NODE。TX Power Level(发射功率):用来让手机计算距离。
3 蓝牙点灯
第二个实验就具体很多,通过蓝牙操作板子上的IO口。本来觉得这个部分应该会比较快,结果没想到非常折腾。。。
3.1 两套代码
import bluetooth
import machine
import time
from micropython import const
# 1. 配置板载 LED(ESP32-C3 常用引脚为 8,根据实际板子可调整)
LED_PIN = 8
led = machine.Pin(LED_PIN, machine.Pin.OUT)
led.value(0) # 默认灭
# 2. BLE 事件常量定义
_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_WRITE = const(3)
# 3. 蓝牙 UUID 定义(采用通用的 LED/Button 服务标准)
# 服务的 UUID: 00001523-1212-efde-1523-785feabcd123
_LED_SERVICE_UUID = bluetooth.UUID("00001523-1212-efde-1523-785feabcd123")
# 控制特性的 UUID (可写): 00001525-1212-efde-1523-785feabcd123
_LED_CHAR_UUID = (
bluetooth.UUID("00001525-1212-efde-1523-785feabcd123"),
bluetooth.FLAG_WRITE | bluetooth.FLAG_READ,
)
_LED_SERVICE = (_LED_SERVICE_UUID, (_LED_CHAR_UUID,),)
class BLE_LED_Controller:
def __init__(self, name="AI_Glasses_BLE"):
self._ble = bluetooth.BLE()
self._ble.active(True)
self._ble.irq(self._ble_irq)
# 注册 GATT 服务
((self._handle,),) = self._ble.gatts_register_services((_LED_SERVICE,))
# 初始化特性值(初始为 0x00,表示灭)
self._ble.gatts_write(self._handle, bytes([0]))
# 组装广播数据 (包含设备名称和公开的服务UUID)
self._name = name
self._payload = self._get_advertising_payload()
self._advertise()
print(f"BLE 设备已启动,正在广播名称: {self._name}")
def _get_advertising_payload(self):
# 构造标准的 BLE 广播数据包
payload = bytearray()
# 1. 广播 Flag
payload.append(2) # 长度
payload.append(0x01) # 类别: Flags
payload.append(0x06) # 一般可发现模式 + 不支持 BR/EDR
# 2. 广播名称
name_bytes = self._name.encode('utf-8')
payload.append(len(name_bytes) + 1)
payload.append(0x09) # 类别: 完整设备名称
payload.extend(name_bytes)
return payload
def _advertise(self):
# 开始广播,每100ms广播一次
self._ble.gap_advertise(250000, adv_data=self._payload)
def _ble_irq(self, event, data):
if event == _IRQ_CENTRAL_CONNECT:
conn_handle, addr_type, addr = data
# 转换 MAC 地址打印出来,确认手机确实连过来了
mac = ":".join([f"{b:02x}" for b in addr])
print(f"收到物理层连接连接! 句柄: {conn_handle}, 手机MAC: {mac}")
elif event == _IRQ_CENTRAL_DISCONNECT:
conn_handle, addr_type, addr = data
print("连接断开或握手失败,重新开始广播...")
self._advertise()
elif event == _IRQ_GATTS_WRITE:
conn_handle, value_handle = data
if value_handle == self._handle:
current_value = self._ble.gatts_read(self._handle)
if current_value:
cmd = current_value[0]
if cmd in (1, 0x31):
led.value(1)
print("【BLE指令】点亮 LED")
elif cmd in (0, 0x30):
led.value(0)
print("【BLE指令】熄灭 LED")
# 启动蓝牙服务
ble_ctrl = BLE_LED_Controller()
上面的代码不用细看了,是不行的。。。
下面的代码改了很久,是可以的。。。不知道是不是因为短UUID的原因。
import bluetooth
import machine
from micropython import const
_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_WRITE = const(3)
# 改用16位短UUID(C3 v1.28稳定可用,避开128bit内核bug)
_SRV_UUID = bluetooth.UUID(0xFF00)
_CHAR_TX = (bluetooth.UUID(0xFF01), bluetooth.FLAG_NOTIFY)
_CHAR_RX = (bluetooth.UUID(0xFF02), bluetooth.FLAG_WRITE)
_SRV = (_SRV_UUID, (_CHAR_TX, _CHAR_RX))
LED_PIN = 8
led = machine.Pin(LED_PIN, machine.Pin.OUT)
led.value(1)
class BLE_ShortUUID:
def __init__(self,name="C3_SHORT"):
self.ble = bluetooth.BLE()
self.ble.active(True)
# 官方标准顺序:先注册,后irq
((self.tx_h,self.rx_h),)=self.ble.gatts_register_services((_SRV,))
self.ble.irq(self._irq)
self.conn = None
self.advertise()
def _irq(self,event,data):
if event == _IRQ_CENTRAL_CONNECT:
self.conn,_,_=data
print("连上了")
elif event == _IRQ_CENTRAL_DISCONNECT:
self.conn=None
print("断连,重广播")
self.advertise()
elif event == _IRQ_GATTS_WRITE:
_,h=data
dat=self.ble.gatts_read(h)
print("收到",dat)
if dat[0] == 1 or dat[0] == ord('1'):
led.value(0)
print("开灯")
elif dat[0] == 0 or dat[0] == ord('0'):
led.value(1)
print("关灯")
if self.conn:
self.ble.gatts_notify(self.conn,self.tx_h,dat)
def advertise(self):
adv = bytearray(b'\x02\x01\x06')
# 广播带上16位服务UUID 0x03=Complete 16bit service
adv += b'\x03\x03\x00\xff'
# 设备名
n=b"C3_SHORT"
adv += bytes([len(n)+1,0x09])+n
self.ble.gap_advertise(300000,adv_data=adv)
dev=BLE_ShortUUID("C3_SHORT")
3.2 运行结果
具体使用真是折腾,苹果手机还无法连接。换成小米安卓机勉强可以了,但是连接也很吃力,大概3次成功1次(原因不明)。而且需要换成另外一家的BLE调试助手软件。nRF可以发现但是无法连接。
真的不明白BLE这块怎么这么多随机性。。。
终于服务能出现了。

点击最下面可以write的那个,输入1就可以开灯,0就可以关灯。

具体的效果:

3.3 代码分析
再详细看看代码,改动大概就是更换了16位短UUID、重排注册时序、纯手工拼装广播包。具体是那部分影响的,容我稍后再看。
详细看看代码,大概有以下的步骤。
首先是定义服务。
_SRV_UUID = bluetooth.UUID(0xFF00)
_CHAR_TX = (bluetooth.UUID(0xFF01), bluetooth.FLAG_NOTIFY)
_CHAR_RX = (bluetooth.UUID(0xFF02), bluetooth.FLAG_WRITE)
_SRV = (_SRV_UUID, (_CHAR_TX, _CHAR_RX))
这里用到了python的元组Tuple,元组我稍微有点忘了。二进制的格式就是刚刚说的[长度 (1B) + 类型 (1B) + 数据 (N 字节)]
最后的二进制(小端序)如下:
// 蓝牙底层 ATT 属性表的二进制表达
{
// 1. 主服务
.handle = [0x01, 0x00],
.uuid = [0x00, 0x28], // 0x2800 (GATT_PRIMARY_SERVICE_UUID)
.value = [0x00, 0xFF], // 服务 UUID: 0xFF00// 2. TX 特征声明
.handle = [0x02, 0x00],
.uuid = [0x03, 0x28], // 0x2803 (GATT_CHARACTERISTIC_UUID)
.value = [0x10, 0x03, 0x00, 0x01, 0xFF], // Notify | 句柄0x0003 | UUID 0xFF01// 3. TX 特征物理值 (数据缓冲区)
.handle = [0x03, 0x00],
.uuid = [0x01, 0xFF], // 0xFF01
.value = [...], // 实际调用 tx.notify() 时,数据从这里发出去// 4. RX 特征声明
.handle = [0x04, 0x00],
.uuid = [0x03, 0x28], // 0x2803 (GATT_CHARACTERISTIC_UUID)
.value = [0x08, 0x05, 0x00, 0x02, 0xFF], // Write | 句柄0x0005 | UUID 0xFF02// 5. RX 特征物理值 (数据缓冲区)
.handle = [0x05, 0x00],
.uuid = [0x02, 0xFF], // 0xFF02
.value = [...] // 手机端写入数据时,数据落在此缓冲区,并触发 IRQ_GATTS_WRITE 事件
}
然后是将服务注册到gatts。
((self.tx_h,self.rx_h),)=self.ble.gatts_register_services((_SRV,))
就只有一句代码。注册服务到gatts。这里看看这个gatts。
GATT 的全称是 Generic Attribute Profile(通用属性配置文件)。
它是蓝牙协议栈(Bluetooth Stack)中专门用来组织数据、定义数据结构的官方标准。在 BLE 通信中,所有想传输的数据(比如:船的 GPS 坐标、水质的 pH 值、或者你刚才代码里的 TX/RX 通道),都必须打包成一条条的“属性(Attribute)”。GATT 规定了这些属性必须按照 “服务(Service)-> 特征(Characteristic)-> 描述符(Descriptor)” 的层级结构来像套娃一样组织。
在蓝牙建立连接后,设备会被划分为两种角色:GATT Server(服务器) 和 GATT Client(客户端)。
GATT Server (GATTS): 保存数据、持有属性表(Attribute Table)的设备。 在现在的场景下,运行 MicroPython 的这块板子就是 GATTS。它在自己的 RAM 内存里开辟了一块空间,把刚才生成的二进制属性表(包含
0xFF00服务、0xFF01特征等)保存在内存中。GATT Client (GATTC): 不保存数据,专门跑过来查数据、或者写数据的设备。 比如手机、或者其它Linux 主控。手机连上板子后,会主动发起“服务发现(Service Discovery)”,像翻菜单一样去读板子里的 GATTS 属性表。
在连上蓝牙前,ESP32C3板子在发广播(Peripheral/从机),手机在扫描(Central/主机)。一旦连上后,角色就变成了:ESP32C3板子是 GATTS(提供数据源),手机是 GATTC(消费数据)。
之后是广播,这个部分就和第一个实验差不多了。
这个就不多写了。。。
最后就是回调。
其实也很简单,就是self.ble.irq(self._irq)。然后一个回调函数_irq(self,event,data),这里有两个参数,一个event,表示是类型的事件。一个data,就是事件里面的具体数据。
处理完具体流程之后,使用self.ble.gatts_notify(self.conn,self.tx_h,dat)将数据回显到手机。
3.4 遗留问题
之前还遇到一个问题,链接困难。这里也看看解决办法。
大概查了一下可能有下面的方法,空了尝试一下吧。
def _irq(self,event,data): if event == _IRQ_CENTRAL_CONNECT: self.conn, _, _ = data # 【核心优化 1】一旦连上,立刻停止广播!!让射频专心进行GATT握手 self.ble.gap_advertise(None)
# 【核心优化 2】改为 62.5ms 或 100ms 广播间隔,大幅提升连接成功率 self.ble.gap_advertise(62500, adv_data=adv)
蓝牙连接成功只是第一步(物理层通了),接下来要进行极其麻烦的 GATT 属性交换。 每个手机厂牌(Apple、小米、华为)为了省电,在蓝牙堆栈里写了大量的私有白名单、安全加密策略和 MTU 握手裁决。比如:
苹果:连接间隔必须在 15ms~30ms,必须符合 1:4 的广播比例,否则老子直接断开。
安卓:我想发大包,强行甩过去一个 247 字节的 MTU 请求,你板子要是 3 毫秒内不回我,我就认为你死了。 这种应用层和底层链路层深度耦合的特性,导致蓝牙协议栈(特别是 MicroPython 移植的轻量级 NimBLE)面对各种魔改手机系统时,稍有不慎就会在握手阶段触发 Bug。
更多推荐

所有评论(0)