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。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐