11.12. 동시 역할과 다중 연결

peripheral 및 central 페이지에서는 각각 한 번에 단일 연결을 처리하는 단일 역할을 보여줍니다. 실제 애플리케이션은 그렇게 단순한 경우가 드뭅니다. 카메라는 심박 측정 스트랩에서 값을 읽으면서 동시에 휴대폰에 센서 서비스를 게시할 수도 있고, 동시에 페어링된 두 대의 휴대폰으로부터 연결을 수락할 수도 있습니다. aioble API는 이 두 가지 패턴을 모두 지원하는데, 라디오가 내부적으로 멀티플렉싱하고 모든 작업이 이미 코루틴이기 때문입니다. 코루틴을 더 실행하면 하나의 이벤트 루프에서 작업이 병렬로 처리됩니다.

이 페이지에서는 자주 등장하는 패턴들을 모았습니다.

11.12.1. 여러 클라이언트가 하나의 peripheral에 연결하기

주변장치로 동작하기 의 단순한 peripheral 루프는 한 번에 연결된 하나의 central만 처리합니다:

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 로 호출하면 알림이 구독한 모든 클라이언트로 전송됩니다. 한 클라이언트에게만 도달해야 하는 지정된 푸시는 특정 DeviceConnection 인자와 함께 notify() / indicate() 를 사용합니다.

팬아웃은 작게 유지하세요. 유지되는 각 연결은 라디오 시간, RAM, 그리고 컨트롤러 연결 테이블의 슬롯을 소비하며, 카메라는 수십 개 클라이언트를 위한 허브로 설계되지 않았습니다. central 두세 개(휴대폰, 태블릿, 동반 마이크로컨트롤러)는 충분히 가능한 범위이며, 그 이상이 필요한 설계는 카메라가 아니라 제대로 된 BLE 게이트웨이에 적합합니다.

11.12.2. 동시에 peripheral과 central 역할 수행하기

카메라는 휴대폰에 자체 서비스를 광고하면서 동시에 웨어러블에 대한 central 역할을 할 수 있습니다. 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가 설계된 저대역폭 통신에서는 보통 그 비용이 눈에 띄지 않습니다.

염두에 두어야 할 두 가지 실질적인 사항:

  • 두 역할 모두 자체 코루틴에 있어야 합니다. 연결된 central을 처리하는 클라이언트별 작업 내부에서 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 코루틴은 TCP 코루틴과 동일한 방식으로 gather / wait_for / Event / Lock 에 연결됩니다.

11.12.4. 한 역할은 주기마다 끝나고 다른 역할은 그렇지 않을 때

배터리 구동 카메라의 한 주기는 다음과 같이 보일 수 있습니다:

  • 깨어납니다.

  • central로서 페어링된 센서 스트랩에서 새로운 값을 읽습니다.

  • peripheral로서 휴대폰이 그날의 측정값을 다운로드하도록 광고합니다.

  • 둘 다 유휴 상태일 때 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