11.12. Funções simultâneas e múltiplas conexões

As páginas sobre peripheral e central mostram, cada uma, uma única função atendendo uma única conexão por vez. Aplicações reais raramente são tão simples. Uma câmera pode publicar um serviço de sensor para um celular enquanto também lê valores de uma cinta de frequência cardíaca, ou aceitar conexões de dois celulares pareados simultaneamente. A API aioble suporta ambos os padrões porque o rádio faz multiplexação por baixo dos panos e cada operação já é uma corrotina – execute mais corrotinas e o trabalho acontece em paralelo em um único loop de eventos.

Esta página reúne os padrões que surgem.

11.12.1. Múltiplos clientes conectando-se a um peripheral

O loop simples de peripheral em Atuando como um periférico atende um central conectado por vez:

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

O padrão que permite aceitar mais de um cliente é disparar uma tarefa por conexão e voltar imediatamente ao loop em aioble.advertise() para que o próximo cliente também possa se conectar:

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))

Cada conexão é executada em sua própria tarefa. O banco de dados GATT é compartilhado – todos os clientes veem os mesmos serviços e características – mas o estado por conexão fica dentro da tarefa. As notificações vão para todos os clientes inscritos quando write() é chamado com send_update=True; envios direcionados que devem alcançar apenas um cliente usam notify() / indicate() com o argumento DeviceConnection específico.

Mantenha o fan-out pequeno. Cada conexão mantida consome tempo de rádio, RAM e um slot na tabela de conexões do controlador, e a câmera não foi projetada para ser um hub para dezenas de clientes. Dois ou três centrais (um celular, um tablet, um microcontrolador companheiro) estão perfeitamente ao alcance; projetos que precisam de mais pertencem a um gateway BLE adequado, e não à câmera.

11.12.2. Peripheral e central ao mesmo tempo

Uma câmera pode anunciar seu próprio serviço para um celular enquanto também atua como central para um dispositivo vestível. aioble não possui um interruptor de “modo” – o loop de anúncio e o loop de varredura e conexão são apenas corrotinas independentes:

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())

O rádio compartilha o tempo entre as duas funções – uma janela de varredura aqui, uma rajada de anúncio ali, um evento de conexão quando uma das conexões de qualquer lado está ativa. O throughput de cada função cai quando ambas estão ativas, porque o rádio não pode literalmente fazer duas coisas ao mesmo tempo, mas para as conversas de baixa largura de banda para as quais o BLE foi projetado, o custo geralmente é imperceptível.

Duas coisas práticas a ter em mente:

  • Ambas as funções precisam estar em sua própria corrotina. Chamar aioble.scan() de dentro da tarefa por cliente que lida com um central conectado funciona, mas bloqueia as notificações desse cliente até que a varredura termine – em vez disso, execute a varredura em sua própria tarefa.

  • Apenas uma varredura é executada por vez. Se você precisa varrer a partir de dois lugares diferentes, compartilhe o iterador de varredura ou coordene o acesso; não entre em dois gerenciadores de contexto aioble.scan() em paralelo.

11.12.3. Coordenando múltiplas conexões a partir de uma tarefa

Quando várias conexões precisam ser combinadas em uma única operação lógica – por exemplo, a câmera conversa com dois sensores ao mesmo tempo e só reporta o resultado depois que ambos responderam – as primitivas padrão do asyncio se aplicam diretamente. asyncio.gather() executa as corrotinas por conexão simultaneamente e retorna quando todas terminaram; asyncio.wait_for() adiciona um prazo.

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

É o mesmo padrão que o capítulo sobre asyncio (Asyncio) usa para redes – as corrotinas BLE se encaixam em gather / wait_for / Event / Lock da mesma forma que as de TCP.

11.12.4. Quando uma função termina por ciclo e a outra não

Um ciclo em uma câmera alimentada por bateria pode se parecer com isto:

  • Acordar.

  • Como central, ler valores atualizados de uma cinta de sensor pareada.

  • Como peripheral, anunciar para que um celular baixe as medições do dia.

  • Quando ambas estão ociosas, chamar aioble.stop() e dormir.

O sequenciamento é simples com duas tarefas e um 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