公司动态

BLE GATT通信实战:安卓连接英特尔Edison的嵌入式物联网方案

📅 2026/7/29 12:05:58
BLE GATT通信实战:安卓连接英特尔Edison的嵌入式物联网方案
1. 项目缘起为什么BLE连接Edison是个值得深挖的课题几年前当英特尔® Edison这块集成了Atom双核处理器、Quark微控制器、Wi-Fi和蓝牙的微型计算板刚面世时它被寄予厚望是创客和物联网原型开发的明星。虽然英特尔后来逐渐淡出了这块市场但Edison凭借其强大的计算能力、丰富的接口和完整的Linux系统至今仍在许多嵌入式项目、工业数据采集节点甚至是一些教学实验中发光发热。我手头就还有几块Edison板子在服役负责一些边缘计算和数据预处理的任务。最近在重构一个老旧的数据采集系统时我遇到了一个实际需求现场有几台基于安卓的工业平板或手持终端需要以低功耗、近距离的方式向部署在设备旁的Edison主板实时发送一些配置参数和触发指令。Wi-Fi直连固然可以但在那个电磁环境复杂、Wi-Fi网络拥挤的车间里稳定性是个问题而且功耗也偏高。这时蓝牙低功耗Bluetooth Low Energy, BLE就成了一个非常理想的备选方案。它功耗低、连接快速非常适合这种间歇性、小数据量的通信场景。然而当我真正开始动手实现“通过安卓设备BLE连接英特尔Edison”时发现相关资料已经非常零散且大多停留在几年前的概念演示阶段。无论是Edison端的BLE服务配置还是安卓端的连接与数据交互都有不少坑需要趟过去。这不仅仅是一个简单的“连接”动作它涉及到嵌入式Linux下的BLE守护进程配置、GATT服务与特征值的自定义、安卓BLE API的正确使用、以及跨平台的数据编解码等一系列问题。把这个过程系统地梳理出来对于仍在维护或使用Edison平台的朋友们应该会是一份有价值的参考。2. 理解通信基石BLE GATT模型与项目中的角色映射在动手写代码之前我们必须对BLE通信的核心模型——GATTGeneric Attribute Profile有一个清晰的认识。你可以把它想象成一个结构化的数据服务仓库。在这个仓库里服务器Server拥有这个仓库里面存放着数据。在我们的项目中英特尔Edison将扮演GATT服务器的角色。它需要定义好仓库里有什么“货架”服务每个货架上有什么“商品”特征值以及商品是只读的、只写的还是可以通知的。客户端Client是来仓库访问的顾客。安卓设备将扮演GATT客户端的角色。它负责扫描、发现Edison这个“仓库”连接后根据需求去读取Read、写入Write或订阅通知Notify/Indicate特定的“商品”。GATT的关键层级结构如下服务Service一个逻辑容器代表一个特定的功能单元。例如一个“电池服务”一个“自定义数据收发服务”。每个服务由一个唯一的128位UUID标识。我们可以使用蓝牙技术联盟定义的标准UUID如电池服务是0x180F也可以使用自定义的UUID。特征值Characteristic服务内部的实际数据点。它是客户端与服务器交换数据的载体。一个服务可以包含多个特征值。例如在“自定义数据收发服务”里我们可以定义两个特征值一个用于安卓向Edison发送命令属性为WRITE另一个用于Edison向安卓发送数据属性为NOTIFY。特征值也由UUID标识。描述符Descriptor提供关于特征值的额外信息。其中最重要的一个是客户端特征配置描述符CCCD。当特征值具有NOTIFY或INDICATE属性时客户端必须通过向这个CCCD写入0x0001启用通知或0x0002启用指示来订阅数据服务器端才能主动推送数据。对于我们的项目设计一个最简单的自定义服务就足够了。我们将创建一个自定义UUID的服务里面包含两个特征值TX_CHAR_UUID用于安卓设备向Edison写入数据。属性为WRITE。RX_CHAR_UUID用于Edison向安卓设备通知数据。属性为NOTIFY。同时它需要附带一个CCCD。这个模型是后续所有配置和代码编写的基础。安卓端和Edison端的程序都将围绕这个模型进行构建。3. Edison端配置在Yocto Linux上搭建BLE服务器Edison运行的是基于Yocto Project构建的Linux系统。配置BLE服务器我们主要与bluez——Linux官方的蓝牙协议栈——打交道。3.1 系统准备与BlueZ确认首先通过串口或SSH登录到Edison。我们需要确保系统支持BLE并且bluez的版本在5.0以上包含BLE支持。# 查看BlueZ版本 bluetoothd -v # 或 hciconfig -v如果版本过低Edison原始镜像可能版本较老可能需要更新系统或手动编译安装新版本bluez这个过程比较繁琐建议先尝试使用现有版本。通常Edison的后期镜像已经包含了较新的BlueZ。启动蓝牙并确保控制器已启动# 启动蓝牙服务如果未运行 systemctl start bluetooth # 开启hci0控制器 hciconfig hci0 up # 检查控制器状态应看到UP RUNNING并且Features中包含LE支持 hciconfig hci03.2 使用Bluetoothctl交互式配置GATT服务在BlueZ 5.x中一种相对直接的方式是使用bluetoothctl命令行工具配合gatttool进行交互式测试但为了构建持久的服务我们推荐使用D-Bus API或者编写小型守护进程。这里介绍一种利用bluetoothctl和脚本的简易方法适合原型验证。首先进入bluetoothctl交互环境bluetoothctl在bluetoothctl中执行以下命令# 设置蓝牙可被发现 discoverable on # 设置蓝牙可被连接 pairable on # 开始广告让安卓设备能扫描到 advertise on但这样广告的是默认的基础信息。要广告我们自定义的GATT服务需要更底层的工具。我们可以编写一个简单的Python脚本利用bluez的D-Bus接口来注册服务。这是更可靠的生产方式。3.3 编写Python BLE服务器推荐在Edison上创建一个Python脚本例如ble_server.py。你需要安装dbus和gobject或gi模块它们通常已预装。#!/usr/bin/python import dbus import dbus.service import dbus.mainloop.glib from gi.repository import GLib import sys # 使用自定义UUID避免与标准服务冲突 # 可以使用在线UUID生成器生成这里是一个示例 SERVICE_UUID 12345678-1234-5678-1234-56789abcdef0 TX_CHAR_UUID 12345678-1234-5678-1234-56789abcdef1 # 安卓写Edison读 RX_CHAR_UUID 12345678-1234-5678-1234-56789abcdef2 # Edison写安卓读通知 # 定义GATT特征值的属性标志位 CHAR_READ read CHAR_WRITE write CHAR_NOTIFY notify class Application(dbus.service.Object): def __init__(self, bus): self.path / dbus.service.Object.__init__(self, bus, self.path) # 初始化一个用于通知的数据缓存 self.notify_data Hello from Edison def get_path(self): return dbus.ObjectPath(self.path) dbus.service.method(org.freedesktop.DBus.Properties, in_signatures, out_signaturea{sv}) def GetAll(self, interface): if interface org.bluez.GattApplication1: return { Services: [dbus.ObjectPath(/service)] } else: return {} class Service(dbus.service.Object): def __init__(self, bus, index, uuid, primary): self.path /service self.uuid uuid self.primary primary self.characteristics [] dbus.service.Object.__init__(self, bus, self.path) def get_properties(self): return { org.bluez.GattService1: { UUID: self.uuid, Primary: self.primary, Characteristics: dbus.Array( [c.get_path() for c in self.characteristics], signatureo ) } } def get_path(self): return dbus.ObjectPath(self.path) def add_characteristic(self, characteristic): self.characteristics.append(characteristic) dbus.service.method(org.freedesktop.DBus.Properties, in_signatures, out_signaturea{sv}) def GetAll(self, interface): if interface org.bluez.GattService1: return self.get_properties()[interface] else: return {} class Characteristic(dbus.service.Object): def __init__(self, bus, index, uuid, flags, service): self.path service.path /char str(index) self.uuid uuid self.service service self.flags flags self.value [] dbus.service.Object.__init__(self, bus, self.path) service.add_characteristic(self) def get_properties(self): return { org.bluez.GattCharacteristic1: { Service: self.service.get_path(), UUID: self.uuid, Flags: self.flags, Value: dbus.Array(self.value, signaturey) } } def get_path(self): return dbus.ObjectPath(self.path) dbus.service.method(org.freedesktop.DBus.Properties, in_signatures, out_signaturea{sv}) def GetAll(self, interface): if interface org.bluez.GattCharacteristic1: return self.get_properties()[interface] else: return {} dbus.service.method(org.bluez.GattCharacteristic1, in_signaturea{sv}, out_signatureay) def ReadValue(self, options): print(fCharacteristic Read: {self.uuid}) # 返回当前值这里示例返回一个字符串的字节形式 return dbus.Array([dbus.Byte(b) for b in self.value], signaturey) dbus.service.method(org.bluez.GattCharacteristic1, in_signatureay) def WriteValue(self, value, options): print(fCharacteristic Write: {self.uuid}, Value: {list(value)}) self.value list(value) # 这里可以处理接收到的数据例如触发某个动作 # 如果是TX特征安卓写我们可以在这里解析命令 if self.uuid TX_CHAR_UUID: received_str .join([chr(b) for b in value]) print(fReceived from Android: {received_str}) # 示例将接收到的数据反转后准备通过RX特征发送回去 self.service.characteristics[1].value [dbus.Byte(ord(c)) for c in received_str[::-1]] # 触发一个属性变化信号模拟通知实际通知需要更复杂的处理 # 为了简化我们可以在另一个循环里检查并发送 def main(): dbus.mainloop.glib.DBusGMainLoop(set_as_defaultTrue) bus dbus.SystemBus() # 在系统总线上发布应用 app Application(bus) service Service(bus, 0, SERVICE_UUID, True) # 创建特征值 tx_char Characteristic(bus, 0, TX_CHAR_UUID, [CHAR_WRITE], service) rx_char Characteristic(bus, 1, RX_CHAR_UUID, [CHAR_READ, CHAR_NOTIFY], service) # 注册应用 adapter_path /org/bluez/hci0 manager dbus.Interface(bus.get_object(org.bluez, adapter_path), org.bluez.GattManager1) manager.RegisterApplication(app.get_path(), {}) print(BLE GATT Server is running...) print(fService UUID: {SERVICE_UUID}) print(Advertising... (You may need to run bluetoothctl advertise on separately)) # 启动事件循环 loop GLib.MainLoop() try: loop.run() except KeyboardInterrupt: print(Stopping server...) manager.UnregisterApplication(app.get_path()) loop.quit() if __name__ __main__: main()这个脚本创建了一个包含自定义服务的BLE服务器。它定义了一个可写的特征用于接收安卓数据和一个可读、可通知的特征用于向安卓发送数据。脚本运行后服务就注册到了BlueZ中。注意这个示例脚本为了清晰简化了通知Notify的完整实现。完整的通知机制需要处理CCCD的写入事件并在数据准备好后通过PropertiesChanged信号主动发出。上述脚本中的rx_char.value更新后需要额外的逻辑来触发通知。你可以考虑使用一个线程或定时器来检查是否需要发送通知。3.4 运行与调试给脚本执行权限chmod x ble_server.py运行脚本sudo python ble_server.py需要root权限访问D-Bus系统总线在另一个终端使用bluetoothctl开启广告bluetoothctl advertise on现在Edison应该正在广告一个包含自定义UUID12345678-1234-5678-1234-56789abcdef0的BLE服务。4. 安卓端开发连接、发现与数据交互在安卓端我们使用Android SDK提供的BluetoothLeScanner和BluetoothGattAPI。这里以Kotlin代码为例展示核心流程。4.1 权限与配置首先在AndroidManifest.xml中添加必要权限uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- 对于Android 6.0 (API 23)及以上还需要精确定位权限来扫描BLE设备 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-feature android:nameandroid.hardware.bluetooth_le android:requiredtrue/对于Android 12API 31及以上还需要声明BLUETOOTH_CONNECT和BLUETOOTH_SCAN权限并在运行时动态申请。同时后台扫描需要ACCESS_BACKGROUND_LOCATION权限但我们的示例以前台操作为主。4.2 扫描设备在Activity或ViewModel中初始化扫描private val bluetoothAdapter: BluetoothAdapter? by lazy { val bluetoothManager getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothManager.adapter } private val leScanner: BluetoothLeScanner? by lazy { bluetoothAdapter?.bluetoothLeScanner } private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) result.device?.let { device - // 通过设备名称或广告数据中的Service UUID过滤出Edison if (device.name?.contains(Edison) true) { // 或者检查广告数据中的Service UUID列表 result.scanRecord?.serviceUuids?.forEach { uuid - if (uuid.toString().equals(SERVICE_UUID, ignoreCase true)) { leScanner?.stopScan(scanCallback) connectToDevice(device) } } } } } override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.e(BLE, Scan failed with error: $errorCode) } } fun startScan() { if (bluetoothAdapter null || !bluetoothAdapter!!.isEnabled) { // 请求打开蓝牙 return } // 动态申请位置权限API 23 // ... val filters listOfScanFilter() // 可以添加过滤条件如Service UUID val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() leScanner?.startScan(filters, settings, scanCallback) }4.3 连接设备与发现服务扫描到设备后建立GATT连接并发现服务private var bluetoothGatt: BluetoothGatt? null private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) when (newState) { BluetoothProfile.STATE_CONNECTED - { Log.i(BLE, Connected to GATT server.) // 发现服务 gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED - { Log.i(BLE, Disconnected from GATT server.) // 处理断开连接 } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status) if (status BluetoothGatt.GATT_SUCCESS) { // 遍历所有服务找到我们的自定义服务 gatt.services?.forEach { service - if (service.uuid.toString().equals(SERVICE_UUID, ignoreCase true)) { Log.i(BLE, Custom service found.) // 找到特征值 service.characteristics?.forEach { characteristic - when { characteristic.uuid.toString().equals(TX_CHAR_UUID, ignoreCase true) - { txCharacteristic characteristic Log.i(BLE, TX Characteristic found.) } characteristic.uuid.toString().equals(RX_CHAR_UUID, ignoreCase true) - { rxCharacteristic characteristic Log.i(BLE, RX Characteristic found.) // 启用RX特征的通知 enableNotification(gatt, characteristic) } } } } } } else { Log.w(BLE, onServicesDiscovered received: $status) } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { super.onCharacteristicChanged(gatt, characteristic) // 当RX特征值改变Edison发送通知时会回调到这里 if (characteristic.uuid.toString().equals(RX_CHAR_UUID, ignoreCase true)) { val receivedData characteristic.value val message String(receivedData, Charsets.UTF_8) Log.i(BLE, Received from Edison: $message) // 在主线程更新UI runOnUiThread { // 显示接收到的数据 } } } // ... 其他回调如 onCharacteristicRead, onCharacteristicWrite } private fun connectToDevice(device: BluetoothDevice) { // 使用设备地址连接第二个参数autoConnect设为false以获得更快的连接速度 bluetoothGatt device.connectGatt(context, false, gattCallback) } private fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { // 1. 设置本地客户端特征配置描述符CCCD为启用通知 gatt.setCharacteristicNotification(characteristic, true) // 2. 找到CCCD描述符并写入启用指令 val descriptor characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) descriptor?.let { it.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(it) } }4.4 数据写入与读取连接建立并发现服务后就可以进行数据交互了。向Edison写入数据通过TX特征fun sendCommandToEdison(command: String) { txCharacteristic?.let { char - char.value command.toByteArray(Charsets.UTF_8) bluetoothGatt?.writeCharacteristic(char) } }从Edison读取数据通过RX特征通常我们使用通知Notify来接收数据这是异步、高效的。如果确实需要主动读取可以调用bluetoothGatt?.readCharacteristic(rxCharacteristic)读取的结果会在onCharacteristicRead回调中返回。5. 实战中的关键陷阱与避坑指南将理论转化为稳定可用的连接中间充满了细节上的“坑”。以下是我在多次调试中总结的关键点5.1 Edison端BlueZ版本与D-Bus接口的兼容性Edison原始的Ubilinux或早期Yocto镜像可能搭载BlueZ 4.x或5.0早期版本其D-Bus API可能与上述Python脚本不兼容。最常见的错误是org.bluez.GattApplication1或org.bluez.GattManager1接口找不到。排查与解决确认版本bluetoothd -v。如果版本低于5.43很多高级GATT Server API可能不支持。降级方案如果无法升级可以考虑使用gatttool进行模拟但这只能创建临时的、简单的特征值不适合持久化服务。更好的方案是使用Node.js的bleno库或C语言直接编写bluetoothd的插件。对于原型开发bleno是更快捷的选择。你可以在Edison上安装Node.js然后通过npm install bleno安装并编写一个Node.js版本的BLE服务器其API更友好社区示例也多。权限问题Python脚本必须使用sudo运行因为需要访问系统D-Bus总线。确保当前用户有权限。5.2 安卓端连接生命周期与回调线程安卓的BluetoothGattAPI是异步且基于回调的所有操作连接、发现服务、读写都不会阻塞主线程。但这也带来了状态管理的复杂性。常见问题连接失败或超时确保Edison正在广告且安卓设备没有连接到太多BLE设备有连接数限制。连接时autoConnect参数设为false可以更快建立连接但重连逻辑需要自己实现。onServicesDiscovered未被调用连接成功后必须显式调用gatt.discoverServices()。并且这个操作是异步的需要等待回调。通知不生效这是最高频的坑。必须严格按照两步走先setCharacteristicNotification(true)再向对应的CCCD描述符写入启用值。顺序不能错且必须在onServicesDiscovered成功之后进行。写入CCCD也是一个异步操作成功后会触发onDescriptorWrite回调。回调不在主线程所有BluetoothGattCallback的回调都发生在Binder线程池中而不是主线程。因此在回调中更新UI必须使用runOnUiThread或Handler。资源泄漏连接使用完毕后必须调用bluetoothGatt?.close()来释放底层资源。否则会导致后续连接失败或系统蓝牙资源耗尽。5.3 数据格式与传输可靠性BLE的单个数据包大小有限通常MTU是23字节减去3字节开销有效载荷约20字节。传输较长的数据需要分包。处理策略协议设计在应用层设计简单的协议。例如在数据前加一个长度字节或者定义特定的开始和结束标志。Edison和安卓端遵循同样的拆包和组包逻辑。MTU协商连接后可以尝试请求更大的MTUMaximum Transmission Unit。在安卓端调用bluetoothGatt?.requestMtu(512)然后在onMtuChanged回调中确认实际生效的MTU值。但这取决于双方硬件和固件的支持不一定能成功。流控与确认对于关键指令可以考虑实现简单的“发送-确认”机制。例如安卓写入一个命令到TX特征Edison收到后通过RX特征回发一个“ACK”确认。避免因丢包导致状态不一致。5.4 后台运行与连接保持安卓应用退到后台后为了省电系统可能会限制或停止蓝牙扫描和连接活动。应对措施使用前台服务Foreground Service如果需要长时间保持连接应在连接开始时启动一个前台服务并显示一个持续的通知。这能显著降低被系统杀死的概率。使用BluetoothGatt的autoConnect参数在connectGatt时将其设为true系统会在连接断开后尝试自动重连。但这通常比手动连接慢。处理断开重连在onConnectionStateChange中监听断开事件然后实现一个带指数退避的重连逻辑。注意避免过于频繁的重连尝试。6. 进阶考量安全、功耗与多设备连接当基础连接跑通后根据项目需求可能还需要考虑以下方面6.1 安全配对Bonding默认的BLE连接是不加密的。如果传输敏感数据需要启用配对Bonding以进行加密。Edison端在bluetoothctl中设置agent on和default-agent并设置pairable yes。当安卓端发起配对请求时Edison端可能需要确认或输入PIN码如果设置了Just Works模式则自动配对。安卓端在发起连接或尝试读写加密特征时系统会自动弹出配对请求对话框。你也可以通过createBond()方法主动发起配对。6.2 Edison端功耗优化Edison作为服务器持续广告功耗比客户端扫描要高。可以通过间歇性广告来降低功耗。使用bluetoothctl或hciconfig可以设置广告间隔和参数但这需要更底层的控制。在Python脚本中可以通过D-Bus调用org.bluez.LEAdvertisingManager1接口来配置广告参数。6.3 一对多连接一个Edison理论上可以同时连接多个安卓客户端。BlueZ的GATT服务器模型本身支持多客户端连接。但在应用层你需要管理来自不同客户端的数据流避免冲突。可以为每个连接维护一个上下文或者设计一个基于队列的指令处理机制。实现这个项目最耗时的部分往往不是核心代码而是环境配置和异常调试。尤其是Edison端相对陈旧的环境需要耐心寻找适合的库和版本。一旦打通BLE作为一种灵活、低功耗的短距离通信方式能为很多物联网和边缘计算场景提供可靠的补充连接方案。