11.12. Одновременные роли и несколько подключений

Страницы о периферийном и центральном устройстве показывают, как одна роль в каждый момент времени обслуживает одно подключение. Реальные приложения редко так просты. Камера может публиковать сервис датчика для телефона, одновременно считывая значения с нагрудного пульсометра, или принимать подключения от двух одновременно сопряжённых телефонов. API aioble поддерживает оба сценария, поскольку радиомодуль мультиплексирует работу под капотом, а каждая операция уже является корутиной – запустите больше корутин, и работа будет выполняться параллельно в одном цикле событий.

На этой странице собраны типичные паттерны.

11.12.1. Несколько клиентов подключаются к одному периферийному устройству

Простой цикл периферийного устройства на странице Работа в роли периферийного устройства обслуживает одно подключённое центральное устройство в каждый момент времени:

async def serve():
    while True:
        connection = await aioble.advertise(...)
        async with connection:
            await connection.disconnected()

Паттерн, позволяющий принимать более одного клиента, заключается в том, чтобы запускать отдельную задачу для каждого подключения и сразу же возвращаться к aioble.advertise(), чтобы следующий клиент тоже мог подключиться:

async def handle_client(connection):
    async with connection:
        # ... per-client work: subscribe their CCCDs,
        # push notifications, await writes ...
        await connection.disconnected()

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

Каждое подключение выполняется в своей собственной задаче. База данных GATT является общей – все клиенты видят одни и те же сервисы и характеристики – но состояние, относящееся к отдельному подключению, хранится внутри задачи. Уведомления отправляются каждому подписанному клиенту при вызове write() с send_update=True; направленные отправки, которые должны достигать только одного клиента, используют notify() / indicate() с конкретным аргументом DeviceConnection.

Не делайте веер слишком широким. Каждое удерживаемое подключение расходует радиовремя, ОЗУ и слот в таблице подключений контроллера, а камера не предназначена для роли концентратора для десятков клиентов. Два-три центральных устройства (телефон, планшет, сопутствующий микроконтроллер) вполне достижимы; решения, которым требуется больше, относятся к полноценному шлюзу BLE, а не к камере.

11.12.2. Периферийное и центральное устройство одновременно

Камера может рекламировать свой собственный сервис телефону, одновременно выступая в роли центрального устройства для носимого гаджета. В aioble нет переключателя «режима» – цикл рекламы и цикл сканирования с подключением – это просто независимые корутины:

async def be_peripheral():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-hub",
            services=[ENV_SERVICE],
        )
        asyncio.create_task(handle_client(connection))

async def be_central():
    while True:
        sensor = await find_sensor()
        if sensor is None:
            await asyncio.sleep(5)
            continue
        try:
            async with await sensor.connect() as conn:
                await stream_from_sensor(conn)
        except aioble.DeviceDisconnectedError:
            pass

async def main():
    await asyncio.gather(be_peripheral(), be_central())

asyncio.run(main())

Радиомодуль распределяет время между двумя ролями – окно сканирования здесь, всплеск рекламы там, событие подключения, когда активно одно из подключений любой стороны. Пропускная способность каждой роли падает, когда обе активны, поскольку радиомодуль не может буквально делать два дела одновременно, но для низкоскоростных обменов, для которых проектировался BLE, эти затраты обычно незаметны.

Две практические вещи, о которых стоит помнить:

  • Обе роли должны находиться в своей собственной корутине. Вызов aioble.scan() изнутри задачи отдельного клиента, обслуживающей подключённое центральное устройство, работает, но блокирует уведомления этого клиента до завершения сканирования – лучше запускайте сканирование в отдельной задаче.

  • В каждый момент времени выполняется только одно сканирование. Если вам нужно сканировать из двух разных мест, используйте общий итератор сканирования или координируйте доступ; не входите в два контекстных менеджера aioble.scan() параллельно.

11.12.3. Координация нескольких подключений из одной задачи

Когда несколько подключений нужно объединить в одну логическую операцию – например, камера общается с двумя датчиками сразу и сообщает результат только после того, как оба ответили – напрямую применимы стандартные примитивы asyncio. asyncio.gather() запускает корутины отдельных подключений конкурентно и возвращается, когда все они завершились; asyncio.wait_for() добавляет крайний срок.

async def read_pair():
    async with await sensor_a.connect() as a:
        async with await sensor_b.connect() as b:
            value_a, value_b = await asyncio.gather(
                read_value(a, A_SERVICE, A_CHAR),
                read_value(b, B_SERVICE, B_CHAR),
            )
            return value_a, value_b

Тот же паттерн, который глава об asyncio (Asyncio) использует для работы с сетью – корутины BLE подключаются к gather / wait_for / Event / Lock так же, как и корутины TCP.

11.12.4. Когда одна роль завершается за цикл, а другая нет

Цикл камеры с питанием от батареи может выглядеть так:

  • Пробуждение.

  • В роли центрального устройства считать свежие значения с сопряжённого нагрудного датчика.

  • В роли периферийного устройства рекламировать себя, чтобы телефон загрузил измерения за день.

  • Когда обе роли простаивают, вызвать aioble.stop() и перейти в сон.

Последовательность действий проста при использовании двух задач и объекта asyncio.Event:

phone_done = asyncio.Event()

async def serve_phone():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-hub",
        services=[ENV_SERVICE],
    )
    async with connection:
        await stream_measurements(connection)
    phone_done.set()

async def read_strap():
    async with await strap.connect() as conn:
        await pull_fresh_values(conn)

async def cycle():
    await asyncio.gather(read_strap(), serve_phone())
    aioble.stop()                              # radio off until next wake