11.9. 作为外围设备

最常见的摄像头侧 BLE 模式是作为外围设备(peripheral)运行——发布一个小型 GATT 数据库、广播自身的存在、接受来自手机或配套设备的连接,并向连接另一端的任何对象流式传输数值。

11.9.1. 构建 GATT 数据库

外围设备在启动时——甚至在打开无线电之前——要做的第一件事,就是构建它打算暴露的数据库,为每个服务和特征构造对象,然后将它们全部注册:

import aioble
import bluetooth

ENV_SERVICE = bluetooth.UUID(0x181A)              # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)                # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F)               # Humidity

env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
    env, TEMP_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
    env, HUMID_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)

aioble.register_services(env)

每个 aioble.Characteristic 只需在构造时将所属服务作为第一个参数传入,即可附加到该服务上。布尔类型的关键字参数(readwritewrite_no_responsenotifyindicate)用于选择允许客户端执行哪些 GATT 操作;传入 False(默认值)表示不设置对应的属性位。

aioble.register_services() 将组装好的树提交给 GATT 服务器。它必须在任何 aioble.advertise() 启动之前调用一次;再次调用会替换之前的数据库。

11.9.2. 广播

数据库就绪后,广播只需一次协程调用,它会等待连接:

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

这些关键字参数直接映射到广播负载字段。name 是本地名称字段;services 是设备承载的服务 UUID 列表(手机侧的扫描器可以据此进行过滤);appearance 是来自标准 16 位 appearance 值的提示,可让中心设备显示合适的图标。厂商专属数据通过 manufacturer=(company_id, data_bytes) 传入。

另有少数较不常用的关键字覆盖了广播标志空间的其余部分:

  • connectable=False ——仅广播模式(永不接受连接)。这是信标式负载的正确选择。

  • limited_disc=True ——使用有限可发现(limited discoverable)标志,而非通用可发现(general discoverable);某些操作系统在配对界面中对这两者的处理方式不同。

  • adv_data / resp_data ——当应用程序需要完全控制布局时使用的原始字节。

  • timeout_ms ——经过固定时间后放弃。默认行为是永久广播。

当中心设备连接时,aioble.advertise() 返回所产生的 aioble.DeviceConnection。外围设备此时停止广播。

11.9.3. 服务单个客户端

外围设备的主循环通常如下所示:

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        print("connected:", connection.device.addr_hex())
        async with connection:
            await connection.disconnected()
        print("disconnected; advertising again")

asyncio.run(serve())

async with connection 使断开连接的清理工作自动完成。disconnected() 是一个协程,它会挂起直到任意一方结束连接——这是一种保持外围设备持续服务、直到中心设备离开后再循环回去进行下一轮广播的简洁方式。

11.9.4. 更新特征

外围设备通过 aioble.Characteristic.write() 更新本地 GATT 数据库:

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

这会改变任何客户端下一次 read 所返回的值。但它本身不会推送新值——已订阅的客户端在客户端轮询或外围设备发送显式通知之前不会看到任何内容。

推送一侧只需在同一次调用上加一个关键字:

temp_char.write(temp_bytes, send_update=True)

send_update=True 会通知(或指示)每一个已订阅该特征的客户端。大多数传感器式代码位于每个连接独立的任务中,该任务循环读取传感器并大约每秒用 send_update=True 写入数值:

async def stream_temperature(connection):
    while connection.is_connected():
        temp_char.write(encode_temperature(read_sensor()), send_update=True)
        await asyncio.sleep(1)

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        async with connection:
            asyncio.create_task(stream_temperature(connection))
            await connection.disconnected()

如果你更希望将通知定向到某个特定客户端,而非整个订阅集合(比如针对该客户端命令的连接私有响应),aioble.Characteristic.notify()indicate() 接受一个 DeviceConnection 参数和一个可选的负载。

11.9.5. 接收写入

相反方向——客户端写入特征——在特征以 write=Truewrite_no_response=True 构造时变得可用。外围设备通过 aioble.Characteristic.written() 等待下一次写入:

cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)

async def handle_commands():
    while True:
        connection, data = await cmd_char.written()
        print("command from", connection.device.addr_hex(), "=", data)

在没有 capture=True 的情况下,written() 仅返回执行写入的连接;新值存放在特征的后备缓冲区中,应用程序通过 read() 获取它。如果在应用程序读取第一个值之前到来了第二次写入,第二个值会在缓冲区中覆盖第一个值,原始值随之丢失——written() 仍会唤醒应用程序,但只在每次“有新内容”时唤醒一次,而非每次写入都唤醒。

capture=True 关键字解决了这个问题。每一次到来的写入都会被追加到一个模块级队列中,written() 会为每一次单独的写入返回一个 (connection, data) 元组——应用程序循环会按到达顺序恰好看到每一项一次。这有两个实际后果:

  • 该队列是有界的,并且在设备上每个启用了 capture 的特征之间共享。短时间内连续的写入突发是可以容忍的;持续的溢出(写入到来的速度快于应用程序排空它们的速度)会静默丢弃最旧的排队条目,并且某个特征上的突发流量可能挤掉来自另一个特征的待处理条目。

  • 对于每个值都很重要的命令式写入,选择 capture=True。对于只关心最新值的状态式特征,则不启用它。

如果来自客户端的读取应由按需运行的代码而非静态值来响应,请重写 on_read()。当读取到来时,该方法会被同步调用;返回 0 以允许读取(将发送来自 write() 的当前值),或返回一个非零的 ATT 错误码以拒绝它:

import time

_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000)            # at most once per second

class TempChar(aioble.Characteristic):
    _last_read_ms = 0

    def on_read(self, connection):
        now = time.ticks_ms()
        if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
            return _ATT_ERR_READ_NOT_PERMITTED
        self._last_read_ms = now
        self.write(encode_temperature(read_sensor()))
        return 0

temp_char = TempChar(env, TEMP_UUID, read=True)

该回调在 GATT 协议栈处理读取之前采样传感器并更新特征的值,因此客户端总能看到最新数据。速率限制可阻止客户端以快于传感器采样速度的频率频繁访问它——任何处于一秒冷却期内的读取都会被以 Read Not Permitted ATT 错误弹回,而不是返回陈旧的值。

11.9.5.1. 更大的后备缓冲区——BufferedCharacteristic

普通 Characteristic 的后备缓冲区宽 20 字节——这是默认 23 字节 MTU 下的实际上限。客户端向普通特征写入超过该长度的内容时,其值会被截断。对于较大的传入值,或为了对应用程序循环稍后会赶上处理的连续写入进行排队,请将该特征声明为 BufferedCharacteristic 并预先选定缓冲区大小:

blob = aioble.BufferedCharacteristic(
    service, BLOB_UUID,
    max_len=512, append=True,
    write=True, capture=True,
)

async def receive_blob():
    while True:
        connection, chunk = await blob.written()
        handle_chunk(connection, chunk)

有两个旋钮将它与普通 Characteristic 区分开来:

  • max_len 是后备缓冲区的大小(以字节计)。选取它以匹配预期客户端会进行的最大单次写入(在 MTU 协商之后)。

  • append=True 使连续写入追加到缓冲区中而非覆盖——这对于接收跨多次写入到达的值(固件更新分块、日志行)很有用。当 append=False 时,缓冲区表现得像普通特征,只是更宽。

所有其他构造参数标志(readwritenotifyindicatecaptureinitial)都原封不动地转发给底层特征。

11.9.6. 标准服务与 SIG 分配的 UUID

坚持使用已分配编号的 UUID(电池服务为 0x180F、环境感测为 0x181A、心率为 0x180D,以此类推)意味着手机的通用蓝牙菜单或任何第三方扫描器应用都能识别设备的用途,而无需任何自定义客户端代码。每个标准特征内部的字节布局也由规范固定——电池电量(0x2A19)是一个 0..100 之间的单字节;温度(0x2A6E)是以 0.01 摄氏度为单位的小端 sint16。对于符合标准服务的应用程序,请一次性生成一个 128 位 UUID,并在设备的各个服务和特征中使用它。

只发布自定义 UUID 的外围设备同样没问题——它只是需要一个了解这些 UUID 的自定义客户端应用。

备注

BLE 数值处处采用小端序——GATT 规范、每个标准特征、每个广播字段皆如此。多字节整数在传输线路上低字节在前。struct 格式字符串中的 < 前缀正是你在编码/解码时所需要的("<h""<H""<I" 等);在小端 MCU 上使用默认的本机字节序目前恰好可行,但明确写出 < 才是稳妥的习惯。

11.9.7. 其背后的无线电

第一个 aioble 协程触及无线电的那一刻,它便开启。在中心设备连接之前,外围设备的时间花在短暂的广播突发与睡眠之间的切换上;连接之后,它遵循协商好的连接间隔。外围设备每次广播都会付出少量功耗代价,因此 aioble.advertise() 上的 interval_us 选择是外围设备在发现延迟与电池寿命之间进行权衡时最直接的旋钮。