用Python和Bluez 5.50在Ubuntu 20.04上构建可被手机发现的BLE设备

在物联网和嵌入式开发领域,蓝牙低功耗(BLE)技术因其低功耗、低成本的特点而广受欢迎。然而,对于许多开发者来说,传统的BLE开发往往意味着复杂的底层协议和C语言环境,这让不少想要快速验证创意的开发者望而却步。本文将带你使用Python这一更易上手的语言,配合Bluez官方接口,在Ubuntu系统上从零构建一个能发出自定义广播包、并能被手机APP扫描到的虚拟BLE设备。

1. 环境准备与基础概念

在开始编码之前,我们需要确保开发环境配置正确,并理解几个关键概念。Ubuntu 20.04是一个理想的开发平台,因为它提供了稳定的Bluez支持,而Python则能让我们专注于逻辑而非底层细节。

1.1 系统与软件要求

首先确认你的系统满足以下要求:

  • Ubuntu 20.04 LTS(物理机或虚拟机均可)
  • Python 3.8或更高版本
  • Bluez 5.50或更高版本
  • D-Bus Python绑定

可以通过以下命令检查已安装的Bluez版本:

bluetoothctl --version

如果尚未安装Bluez,可以使用apt进行安装:

sudo apt update
sudo apt install bluez bluez-tools

1.2 BLE广播基础

BLE设备通过广播(Advertising)来宣告自己的存在。广播数据包可以包含多种信息:

  • 设备名称:人类可读的标识符
  • 服务UUID:设备提供的服务类型
  • 制造商数据:自定义的厂商特定信息
  • 发射功率:帮助设备估算距离

理解这些概念对后续构建广播包至关重要。广播包的最大长度为31字节,需要合理规划各字段的使用。

2. 配置D-Bus与Bluez接口

Bluez通过D-Bus接口暴露其功能,我们需要先建立与D-Bus系统的连接。D-Bus是Linux系统上的一种进程间通信机制,Bluez通过它提供了一套标准化的API。

2.1 初始化D-Bus连接

首先获取系统总线并检查Bluez服务是否可用:

import dbus

# 获取系统总线
bus = dbus.SystemBus()

# 检查Bluez服务是否运行
try:
    bluez_service = 'org.bluez'
    bus.get_object(bluez_service, '/')
    print("Bluez服务可用")
except dbus.exceptions.DBusException as e:
    print("Bluez服务不可用:", e)
    exit(1)

2.2 查找BLE适配器

不是所有的蓝牙适配器都支持BLE,我们需要找到支持BLE的适配器:

BLUEZ_SERVICE_NAME = 'org.bluez'
DBUS_OM_IFACE = 'org.freedesktop.DBus.ObjectManager'
LE_ADVERTISING_MANAGER_IFACE = 'org.bluez.LEAdvertisingManager1'

def find_adapter(bus):
    remote_om = dbus.Interface(
        bus.get_object(BLUEZ_SERVICE_NAME, '/'),
        DBUS_OM_IFACE)
    objects = remote_om.GetManagedObjects()
    
    for path, interfaces in objects.items():
        if LE_ADVERTISING_MANAGER_IFACE in interfaces:
            return path
    
    return None

adapter = find_adapter(bus)
if not adapter:
    print("未找到支持BLE广告的适配器")
    exit(1)

3. 构建BLE广播包

现在我们可以开始构建自定义的广播包了。Bluez通过org.bluez.LEAdvertisement1接口来定义广播内容。

3.1 创建Advertisement类

我们需要继承Bluez提供的Advertisement基类来实现自定义广播:

from gi.repository import GObject
from dbus.mainloop.glib import DBusGMainLoop
import dbus.service

class CustomAdvertisement(dbus.service.Object):
    PATH_BASE = '/org/bluez/example/advertisement'

    def __init__(self, bus, index):
        self.path = self.PATH_BASE + str(index)
        self.bus = bus
        self.ad_type = 'peripheral'
        self.local_name = 'PythonBLE'
        self.service_uuids = ['180A']  # 设备信息服务
        self.manufacturer_data = {0xFFFF: [0x01, 0x02, 0x03, 0x04]}
        self.include_tx_power = True
        
        dbus.service.Object.__init__(self, bus, self.path)

    def get_properties(self):
        properties = dict()
        properties['Type'] = self.ad_type
        
        if self.local_name is not None:
            properties['LocalName'] = dbus.String(self.local_name)
        
        if self.service_uuids is not None:
            properties['ServiceUUIDs'] = dbus.Array(self.service_uuids,
                                                  signature='s')
        
        if self.manufacturer_data is not None:
            properties['ManufacturerData'] = dbus.Dictionary(
                self.manufacturer_data, signature='qv')
        
        if self.include_tx_power:
            properties['Includes'] = dbus.Array(["tx-power"], signature='s')
        
        return {LE_ADVERTISING_MANAGER_IFACE: properties}

    @dbus.service.method(DBUS_PROP_IFACE,
                         in_signature='s',
                         out_signature='a{sv}')
    def GetAll(self, interface):
        if interface != LE_ADVERTISING_MANAGER_IFACE:
            raise InvalidArgsException()
        
        return self.get_properties()[LE_ADVERTISING_MANAGER_IFACE]

3.2 广播包字段详解

广播包中可以包含多种类型的字段,每种都有特定用途:

字段类型说明示例值
LocalName设备名称"MyBLEDevice"
ServiceUUIDs提供的服务UUID列表["180A", "180F"]
ManufacturerData厂商自定义数据{0xFFFF: [0x01,0x02]}
Includes包含的额外信息["tx-power"]

合理组合这些字段可以让你的设备提供丰富的信息,同时保持广播包在31字节的限制内。

4. 注册并启动广播

有了广播包定义后,我们需要将其注册到Bluez的广告管理器中。

4.1 注册广告

def register_ad_cb():
    print("广告注册成功")

def register_ad_error_cb(error):
    print("广告注册失败:", str(error))
    mainloop.quit()

# 获取广告管理器接口
ad_manager = dbus.Interface(
    bus.get_object(BLUEZ_SERVICE_NAME, adapter),
    LE_ADVERTISING_MANAGER_IFACE)

# 创建并注册广告
advertisement = CustomAdvertisement(bus, 0)
ad_manager.RegisterAdvertisement(
    advertisement.get_path(),
    {},
    reply_handler=register_ad_cb,
    error_handler=register_ad_error_cb)

4.2 启动事件循环

最后,我们需要启动GLib事件循环来处理D-Bus消息:

# 初始化主循环
DBusGMainLoop(set_as_default=True)
mainloop = GLib.MainLoop()

try:
    print("BLE广播已启动,使用Ctrl+C停止")
    mainloop.run()
except KeyboardInterrupt:
    mainloop.quit()
    print("\n广播已停止")

5. 验证与调试

完成代码编写后,我们需要验证设备是否能被正确发现。

5.1 使用nRF Connect扫描

在Android设备上安装nRF Connect应用,然后执行以下步骤:

  1. 打开nRF Connect应用
  2. 点击"扫描"按钮
  3. 查找名为"PythonBLE"的设备
  4. 点击设备查看详细广播数据

你应该能看到我们定义的所有广播字段,包括设备名称、服务UUID和制造商数据。

5.2 常见问题排查

如果设备未被发现,可以检查以下几点:

  • 蓝牙适配器状态:确保蓝牙已启用且未被其他进程占用
  • 广播权限:某些系统可能需要root权限来注册广告
  • D-Bus权限:检查Python进程是否有权限访问系统D-Bus
  • 广告间隔:默认间隔可能较长,尝试等待30秒以上

可以通过以下命令检查蓝牙适配器状态:

hciconfig -a

如果遇到权限问题,可以尝试以root身份运行Python脚本:

sudo python3 ble_advertiser.py

6. 进阶自定义选项

基础广播工作后,我们可以进一步定制设备行为。

6.1 调整广播参数

通过org.bluez.LEAdvertisingManager1接口可以设置广播参数:

advertising_properties = {
    'Type': 'peripheral',
    'Interval': dbus.UInt32(200),  # 广播间隔(ms)
    'Timeout': dbus.UInt32(0),     # 0表示不超时
    'Discoverable': dbus.Boolean(True)
}

ad_manager.RegisterAdvertisement(
    advertisement.get_path(),
    advertising_properties,
    reply_handler=register_ad_cb,
    error_handler=register_ad_error_cb)

6.2 动态更新广播数据

Advertisement对象创建后,可以动态更新其属性:

def update_advertisement(adv, new_name, new_data):
    adv.local_name = new_name
    adv.manufacturer_data = new_data
    # 需要重新注册广告才能使更改生效
    ad_manager.UnregisterAdvertisement(adv.get_path())
    ad_manager.RegisterAdvertisement(
        adv.get_path(),
        {},
        reply_handler=register_ad_cb,
        error_handler=register_ad_error_cb)

这种技术可以用于实现动态变化的设备名称或数据,而无需重启整个应用。

7. 性能优化与最佳实践

当你的BLE设备需要长时间运行时,考虑以下优化建议:

  • 广播间隔平衡:较短的间隔提高发现概率但增加功耗
  • 数据精简:确保广播包不超过31字节限制
  • 错误处理:妥善处理D-Bus调用可能抛出的异常
  • 资源清理:程序退出时注销广告

一个健壮的生产级实现应该包含这些元素:

import signal

def shutdown(signum, frame):
    print("\n正在清理资源...")
    ad_manager.UnregisterAdvertisement(advertisement.get_path())
    mainloop.quit()

signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)

在实际项目中,我发现最常遇到的问题是与权限相关的D-Bus错误。确保你的用户属于bluetooth组可以避免大多数此类问题:

sudo usermod -aG bluetooth $USER

另一个实用技巧是在开发过程中启用Bluez的调试输出,这可以帮助理解底层发生了什么:

sudo systemctl stop bluetooth
sudo /usr/lib/bluetooth/bluetoothd -d
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐