11.4. 广播与扫描¶
两台从未相遇过的 BLE 设备必须先找到彼此。网络的解决办法是从共享地址池中为每台设备分配一个地址,并让任意一方通过路由器触达另一方。BLE 没有路由器、没有共享地址池,而且——在大多数设备对之间——根本没有任何先前的关系。Generic Access Profile(GAP,通用访问规范)转而用一种广播-监听模式来解决发现问题。一方 广播(advertise)——以固定的间隔在三个广播信道上发送一个短数据包,描述自己是谁。另一方 扫描(scan)——在同样的三个信道上来回监听这些数据包。
GAP 围绕这一模式定义了四种角色,每一种都是广播与监听的特定组合。
11.4.1. 四种 GAP 角色¶
四种 GAP 角色。纵轴表示设备是否广播;横轴表示设备是否接受(或发起)连接。¶
peripheral(外围设备)广播的数据包表示“我在这里,你可以连接我”。当另一台设备发起连接时,外围设备停止广播并开始处理 GATT 请求。心率带、温度计以及大多数作为传感器的摄像头都充当外围设备。
central(中心设备)扫描外围设备,选择其一,并发起连接。连接后它作为客户端进行 GATT 通信。手机、笔记本电脑以及充当数据收集器的摄像头都是中心设备。
broadcaster(广播者)只广播但从不接受连接。它的广播负载 本身 就是数据——根本没有可供连接的对象。iBeacon 和大多数门店存在感应信标都是广播者。
observer(观察者)扫描那些广播并读取负载,同样从不建立连接。一台监听附近信标并根据所听到的内容采取行动的摄像头就是观察者。
单台设备可以同时扮演多个角色——一台摄像头既可以是发布自身状态的外围设备,同时 又是连接到附近传感器的中心设备。无线电会对这些工作进行多路复用。
11.4.2. 广播数据包包含什么¶
广播数据包很小:31 字节的负载;如果广播方还发布扫描方可临时请求的 扫描响应(scan response),则为 62 字节。负载是一组短小的带类型字段:
Flags(标志)。 是否可连接、通用 / 受限可发现。
Local name(本地名称)。 一个简短、易读的字符串——手机或笔记本电脑的操作系统在其 Bluetooth 菜单中显示的名称。
Service UUIDs(服务 UUID)。 设备所托管的一组 GATT 服务标识符,使扫描方无需先连接即可识别出有用的外围设备。心率带广播
0x180D——标准的心率服务 UUID——手机上的心率应用仅凭这一点就知道这台设备值得连接。Appearance(外观)。 取自 Bluetooth 已分配编号列表的一个 16 位数值(传感器、通用媒体、通用手表……)——向中心设备提示应当显示什么。
Manufacturer-specific data(厂商自定义数据)。 以公司 ID 为前缀的自由格式字节。iBeacon 用此字段携带其 UUID、major 和 minor;自定义应用可以在这里放入任何想放的内容。
广播负载非常紧凑。31 字节的限制使得选择包含哪些内容成为一项真正的设计决策——一个冗长的可读名称很快就会挤占服务 UUID 的空间。aioble.advertise() API 将上述每一项作为关键字参数接收,并为你组装字节,当主数据包占满时会自动溢出到扫描响应中。
11.4.3. 主动扫描与被动扫描¶
扫描方可以以 被动(passive)方式运行,即监听广播数据包并解析收到的内容;也可以以 主动(active)方式运行,即额外向每个广播方发送一个 扫描请求(scan request),并解析返回的扫描响应。
被动扫描只能看到最初的广播数据包(最多 31 字节)。主动扫描使之翻倍——扫描响应是外围设备可用于容纳放不下的字段的另外 31 字节。主动扫描也会消耗双方的电量,因为扫描方要发送,广播方也要额外发送一个数据包,所以它是一种选择,而非默认行为。
在 aioble API 中,aioble.scan() 上的 active=True 切换为该模式,每个 ScanResult 都会公开合并后的 adv_data 加 resp_data,以及 result.name() 和 result.services() 等辅助方法,这些方法隐藏了字节级的解析。
备注
adv_data 和 resp_data 属性是 原始的 广播和扫描响应负载(bytes)。辅助方法——name()、services()、manufacturer()——涵盖了常见的标准字段,在 99% 的情况下都是正确的选择。只有当你需要某个辅助方法不解析的厂商字段时(Eddystone URL、iBeacon 的 UUID/major/minor、自定义广播类型),才去使用原始字节。字节布局是标准的 TLV 格式:每个字段为 length, type, value...。
11.4.4. 广播间隔¶
外围设备广播的频率是功耗与发现延迟之间的权衡。每 20 ms 发出一次的广播几乎会被扫描方立即捕获,但会让无线电一直忙碌并耗尽电池;每秒发一次的广播几乎不耗电,但会让扫描方需要更长时间才能注意到这台设备。
aioble.advertise() 上的 interval_us 以微秒为单位设置间隔:
20,000 到 100,000 us(20 ms - 100 ms)——快速配对、应用期望快速响应、有外接电源的设备。
250,000 到 1,000,000 us(250 ms - 1 s)——对于希望保持可发现性又不想过快耗电的电池供电外围设备而言,是一个合理的默认值。
高于 1,000,000 us——缓慢的后台广播,每隔几秒发送一次位置更新的信标。
扫描方一侧也有自己的旋钮——aioble.scan() 接收 interval_us 和 window_us(扫描方唤醒其无线电的频率,以及每次监听的时长)。默认值即可;唯一常见的改动是在不考虑电量的情况下将两者设为相等以实现连续扫描。
11.4.5. 无连接模式——广播者与观察者¶
作为外围设备 和 充当中心设备 这两页讲解了 API 的 可连接 形态——外围设备接受连接,双方通过 GATT 交换数据。另一种形态是 无连接(connectionless):广播者将负载作为广播发送,范围内的任何观察者都能读取它而无需建立连接。信标、存在感应传感器和单向遥测都属于这一类。
广播者就是带 connectable=False 的 aioble.advertise()。负载由厂商自定义数据携带:
import aioble
import asyncio
import struct
_COMPANY_ID = const(0xFFFF) # 0xFFFF is "no specific vendor"
async def beacon():
seq = 0
while True:
seq = (seq + 1) & 0xFFFF
payload = struct.pack("<H", seq)
await aioble.advertise(
interval_us=500000,
connectable=False,
name="openmv-beacon",
manufacturer=(_COMPANY_ID, payload),
timeout_ms=1000, # one cycle, then loop
)
asyncio.run(beacon())
timeout_ms 关键字让广播调用在一秒后结束;外层循环以下一个序号重新发起广播,使监听方看到新数据。connectable=False 标志正是使该广播成为广播者风格的关键——即便有连接请求到达,摄像头也不会响应。
观察者就是与之对应的只读扫描方。它永久运行 aioble.scan(),解析收到的广播,从不调用 connect():
import aioble
import asyncio
_COMPANY_ID = const(0xFFFF)
async def watch():
async with aioble.scan(duration_ms=0, active=False) as scanner:
async for result in scanner:
for company, data in result.manufacturer(filter=_COMPANY_ID):
print(result.device.addr_hex(),
"rssi", result.rssi, "data", data)
asyncio.run(watch())
duration_ms=0 会一直扫描,直到上下文管理器退出;active=False 让观察者自身的无线电保持静默(不发送扫描响应请求),以实现最低功耗。manufacturer() 上的 filter= 参数会丢弃所有与该公司 ID 不匹配的广播,因此循环只会针对广播者的流量触发。
11.4.6. 从发现到连接¶
一旦中心设备选定要通信的外围设备,它便停止监听,在外围设备最后使用的广播信道上发送一个 连接请求(connect request),随后双方进入链路层的跳频数据信道。此时外围设备通常会停止广播。接下来发生的事情——连接参数、GATT 发现、链路的生命周期——在 连接 中讲解。