Quectel Pi M2 实战:用 BLE 打通手机互联,从搜不到到只有一米

十月中旬在给 Quectel Pi M2 写手机伴侣应用。板子是 RK3576,Debian 13,4 GB 内存,板载蓝牙是双模的。目标不复杂:手机的通知、音乐、来电、导航,都送到板子那块小屏幕上,板子也能反向控制手机。

四条业务,最后都压在一条 BLE 链路上。这里记一下这条链路是怎么搭起来的,以及中间几次比较难受的排查。

为什么没用经典蓝牙

最直觉的做法是一个功能对一个 Profile:通话用 HFP,媒体用 AVRCP,导航数据走 SPP。

在 Debian 上这条路很难走。HFP 要 BlueZ 和 PipeWire 配合,SPP 要 rfcomm bind,而且 Android 10 以后对后台 RFCOMM 连接限制得很死,App 一被杀就断。三套 Profile 就是三套状态机,出问题时互相牵连。

BLE 就没这些包袱。GATT 的协议完全由应用自己定,Android 端在 App 里扫描、连接、订阅,生命周期都在自己手里。代价是分帧、重连、权限、配对,全部要自己写。后面那几个坑,基本都是这个代价的利息。

协议

一个服务,两个特征。

特征 方向 权限
...1001 手机 → 板子 write / write-without-response / encrypt-write
...1002 板子 → 手机 notify / encrypt-notify

encrypt-write 意味着链路必须先加密,也就是手机得先完成配对。所以手机端的顺序是死的:扫到设备,没配对就 createBond(),等系统回调 BOND_BONDED,再 connectGatt(TRANSPORT_LE)。没配对直接写,BlueZ 会给 Insufficient Authentication。

数据是 JSON。一开始想过 protobuf,但 180 字节的量级下省不了多少,加字段还得两端同时改 schema,算了。

180 这个上限是故意压的。BLE 默认 MTU 是 23,ATT 载荷只有 20 字节;Android 端会 requestMtu(247),协商成了能用 244 字节。但有些 ROM 根本不理这个请求,所以协议不依赖协商结果,单帧固定不超过 180 字节,协商成功算赚到。

中文的麻烦在截断。body 给了 54 字节,UTF-8 下一个汉字 3 字节,也就是 18 个字。如果按 Kotlin 的 String.length 截,中文直接超限;如果按字节数组 take(54),最后一个字会被劈成半个,解出来是乱码。

两端的处理是一样的:逐码点累加,超了就停。

def _truncate_utf8(value, byte_limit):
    encoded = value.encode("utf-8")[:byte_limit]
    while encoded:
        try:
            return encoded.decode("utf-8")
        except UnicodeDecodeError:
            encoded = encoded[:-1]
    return ""

字段预算两边写死了同一份:body 54,title/road 36,caller/artist/next_road 30,app_name 24,eta 12。超了就把几个长字段压到 12 字节再拼一次,还超就报错,不发半帧。

导航是增量下发的,一帧只带变化的字段,板子维护一份累积状态再渲染。路线折线太长,按 32 字符切片,一条路线最多 64 片,带 route_id 和序号,乱序到了也能拼回去。

帧大概长这样,前两行手机发,第三行板子发:

{"kind":"notification","app_name":"短信","title":"张三","body":"晚上一起吃饭"}
{"kind":"navigation","turn":"left","distance":120,"road":"人民路","eta":"14:35","active":true}
{"kind":"command","target":"media","action":"next"}

也想过按业务拆成多个特征,权限能细一点,但发现流程和状态机都要翻倍,最后就一个服务两个特征用到底,上层按 kind 分发。

板端

板端没有用 bluetoothctl 去模拟外设,而是直接通过 D-Bus 往 BlueZ 注册对象树。这是 BlueZ 5 做 BLE 外设的正路,但顺序不能错:先 export 对象,再注册配对代理,再注册 GATT 应用,最后注册广播。

bus.export("/com/quectel/m2", Application())              # ObjectManager
bus.export("/com/quectel/m2/service0", GattService())
bus.export("/com/quectel/m2/service0/char0", rx)
bus.export("/com/quectel/m2/service0/char1", tx)

AgentManager1.RegisterAgent("/com/quectel/m2/agent", "NoInputNoOutput")
GattManager1.RegisterApplication("/com/quectel/m2", {})
LEAdvertisingManager1.RegisterAdvertisement("/com/quectel/m2/advertisement0", {})

GetManagedObjects 要返回完整的服务、特征、UUID 和 Flags。这里写漏一个字段,手机能扫到设备,但发现不了服务,看起来完全不像代码写错了。

板子判断手机连没连上,靠的是 StartNotify / StopNotify,不是连接回调。

def StartNotify(self):
    self.notifying = True
    owner.connected = True

def StopNotify(self):
    self.notifying = False
    owner.connected = False

push() 只有在 notifying 为真时才真的发通知。正常断开没问题,异常断开就麻烦了:手机走远、关机、直接关蓝牙,StopNotify 不一定来,connected 就一直停在 true。屏幕继续显示"已连接",导航停在最后一帧。

这个到现在也没完全修。正确的做法是加心跳,手机每隔几秒写一帧 ping,板子超过三倍间隔没收到就判死。属于已知但没做的事。

手机端

连接是一条很长的链:

扫描(按 ServiceUUID 过滤, 12s)
  → 未配对? createBond() → 等 BOND_BONDED
  → connectGatt(TRANSPORT_LE)
  → discoverServices()
  → requestMtu(247)
  → setCharacteristicNotification(true)
  → 写 CCCD → onDescriptorWrite(SUCCESS)
  → linkReady = true

任何一步失败都走同一条退路:clearGatt() 然后重连,退避从 1 秒翻倍到 30 秒封顶。写起来不优雅,但好处是不用为每种失败单独设计恢复路径。

Android 的 GATT 写不能并发。上一笔没回调就发下一笔,会直接失败。所以做了个串行队列,写完一笔才发下一笔,失败重试三次,间隔 250ms 乘次数。队列上限 96 条,满了丢新消息,不阻塞。

通知那边还有一层过滤:自家 App 的通知不转发,常驻通知(音乐播放器那种)不转发,媒体、导航、通话类通知也不转发,只留真正的消息和提醒。不筛的话,板子屏幕会被各种状态栏常驻通知刷满。

Android 12 以后扫描和连接都要动态申请 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT,少一个,回调里直接抛 SecurityException。

搜不到

10 月 20 日下午,手机蓝牙列表里找不到 linaro-alip,板子屏幕上却显示着已连接。

第一反应是射频。后来发现想多了。

板子上执行 bluetoothctl show,关键是这一行:

Advertising Features:
    ActiveInstances: 0

广播实例是 0,意思是根本没有在广播。跟天线、跟手机、跟射频一点关系都没有。

再看服务,voice-agent 是 inactive (dead)。这个服务负责起 GATT 服务和广播,它不跑,手机当然搜不到。

但 unit 是 enabled 的,还写了 Restart=always。为什么重启之后没起来?

systemd-analyze verify 报了个启动顺序环:
  voice-agent.service 与 graphical.target / multi-user.target

原来的 unit 里同时写了:

Wants=graphical.target
After=network-online.target bluetooth.service graphical.target
WantedBy=multi-user.target

graphical.target 本身依赖 multi-user.target,服务又挂在 multi-user 上,环就出来了。systemd 破环的时候会丢掉图形目标的一条启动任务,正好是这条服务。于是它 enabled 但不执行,日志里连失败都没有。

把依赖改成只跟 display-manager.service 就没事了:

[Unit]
Wants=network-online.target
After=network-online.target bluetooth.service display-manager.service

所以 enabled 只能说明它进了依赖图,不能说明它会被执行。判断一个外设服务活没活着,得看它该产生的副作用,比如广播实例数。

名字

搜不到的问题解决之后,又卡在名字上。

板子上同时存在三个名字:

来源 值 在哪能看到
控制器 Name linaro-alip 经典蓝牙扫描、安卓系统设置
适配器 Alias M2 bluetoothctl
BLE 广播 LocalName M2 伴侣 App 的 BLE 扫描

安卓系统设置里的蓝牙列表走的是经典蓝牙发现,显示 BR/EDR 的名字;App 走 BLE 广播,看到的是 LocalName。这两件事从一开始就不是一回事。

更麻烦的是可发现状态会过期。我让用户开 180 秒可发现,时间一到自动变回 Discoverable: no,用户又说找不到。后来改开 10 分钟,还是过期。中间我把 Alias 改成 M2,本意是让它好认,结果和控制器名 linaro-alip 对不上,更乱。

最后是统一回 linaro-alip,启动时把 discoverable-timeout 设成 0,不再过期。

中间还有个小插曲:板子上存着一条叫 Auditore 的配对记录,已配对未连接。安卓大概率把 M2 放进了"已保存的设备",而不是新设备列表,用户一直在"可用设备"里翻。这个从板子上看不出来,只能问用户。

一米

用户报的最后一个问题是距离。一米以内正常,超过一米就断。

我先看了发射功率:

btmgmt info
  TX power range: -9 ~ 11 dBm
  CanSetTxPower: yes

代码里没有任何 SetTxPower 调用,也没有省电模式。11 dBm 是控制器能力上限,不代表当前实际发射功率。

那就只能往硬件上看。M2 的规格里 11 号口是 Wi-Fi 天线,12 号口是蓝牙天线,都是 ECT-4TH 接口。如果 12 号口没接天线,或者接错到 11 号,或者接头不匹配,表现就是这种"贴着脸能用,走两步就断"。

这个我没法隔着 SSH 确认,只能让用户断电之后检查 12 号口。所以到现在,距离问题还是"待硬件复核"。

射频这里容易混的是三件事:能力、配置、实际。btmgmt 报的是能力,bluetoothctl show 报的是配置状态,真实发射功率和天线效率只能靠仪器或者拉距离实测。

现在是什么状态

本地 BLE 相关的单测 9 项都过了:编解码、UTF-8 边界截断、超长拒绝、路线分片乱序重组、命令下发、还有一条专门锁"启动时必须设成持久可发现"的回归测试。

板子上 ActiveInstances 是 1,systemd-analyze verify 不再报环。

手机连着的时候,通知、媒体命令、导航帧都能走通。音频回传(A2DP Sink 那套)没做,也没验收,板子自己出声走的是板载扬声器,不是手机把音频通过蓝牙送过来。

距离一米的问题没解决。连接状态残留没修。长时间跑会不会再出问题,也没系统测过。

代码在 agent/drivers/ble_gatt.py、ble_protocol.py、bluetooth.py,手机端在 M2LinkService.kt 和 BleProtocol.kt。