14.12. Concurrent roles and multiple connections

The peripheral and central pages each show a single role serving a single connection at a time. Real applications are rarely that simple. A camera may publish a sensor service to a phone while also reading values from a heart-rate strap, or accept connections from two simultaneously paired phones. The aioble API supports both patterns because the radio multiplexes underneath and every operation is already a coroutine – run more coroutines, and the work happens in parallel on one event loop.

This page collects the patterns that come up.

14.12.1. Multiple clients connecting to one peripheral

The simple peripheral loop on Acting as a peripheral serves one connected central at a time:

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

The pattern that lets it accept more than one client is to fire off a per-connection task and immediately loop back to aioble.advertise() so the next client can connect too:

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

Each connection runs in its own task. The GATT database is shared – all clients see the same services and characteristics – but per-connection state lives inside the task. Notifications go to every subscribed client when write() is called with send_update=True; directed pushes that should only reach one client use notify() / indicate() with the specific DeviceConnection argument.

Keep the fan-out small. Each held connection costs radio time, RAM, and a slot in the controller’s connection table, and the camera is not designed to be a hub for dozens of clients. Two or three centrals (a phone, a tablet, a companion microcontroller) are well within reach; designs that need more belong on a proper BLE gateway rather than on the cam.

14.12.2. Peripheral and central at the same time

A camera can advertise its own service to a phone while also acting as a central to a wearable. aioble does not have a “mode” switch – the advertise loop and the scan-and-connect loop are just independent coroutines:

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

The radio time-shares between the two roles – a scan window here, an advertising burst there, a connection event when one of either side’s connections is live. Throughput on each role drops when both are active because the radio cannot literally do two things at once, but for the low-bandwidth conversations BLE was designed for, the cost is usually invisible.

Two practical things to keep in mind:

  • Both roles need to be in their own coroutine. Calling aioble.scan() from inside the per-client task that handles a connected central works, but blocks that client’s notifications until the scan finishes – run scanning on its own task instead.

  • Only one scan runs at a time. If you need to scan from two different places, share the scan iterator or coordinate access; do not enter two aioble.scan() context managers in parallel.

14.12.3. Coordinating multiple connections from one task

When several connections need to be combined into one logical operation – for example, the camera talks to two sensors at once and only reports the result after both have answered – the standard asyncio primitives apply directly. asyncio.gather() runs the per-connection coroutines concurrently and returns when all have finished; asyncio.wait_for() adds a deadline.

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

Same pattern that the asyncio chapter (Asyncio) uses for networking – BLE coroutines plug into gather / wait_for / Event / Lock the same way TCP ones do.

14.12.4. When one role finishes per-cycle and the other does not

A cycle in a battery-powered cam might look like:

  • Wake.

  • As a central, read fresh values from a paired sensor strap.

  • As a peripheral, advertise for a phone to download the day’s measurements.

  • When both are idle, call aioble.stop() and sleep.

The sequencing is straightforward with two tasks and an 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