11.12. Współbieżne role i wiele połączeń

Strony dotyczące urządzenia peryferyjnego i centralnego pokazują pojedynczą rolę obsługującą w danym momencie jedno połączenie. Rzeczywiste aplikacje rzadko są tak proste. Kamera może publikować usługę sensora dla telefonu, jednocześnie odczytując wartości z paska do pomiaru tętna, albo przyjmować połączenia od dwóch jednocześnie sparowanych telefonów. API aioble obsługuje oba wzorce, ponieważ radio multipleksuje połączenia pod spodem, a każda operacja jest już korutyną – uruchom więcej korutyn, a praca będzie wykonywana równolegle na jednej pętli zdarzeń.

Ta strona zbiera wzorce, które się pojawiają.

11.12.1. Wielu klientów łączących się z jednym urządzeniem peryferyjnym

Prosta pętla urządzenia peryferyjnego na stronie Działanie jako urządzenie peryferyjne obsługuje w danym momencie jedno podłączone urządzenie centralne:

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

Wzorzec, który pozwala mu przyjmować więcej niż jednego klienta, polega na uruchomieniu zadania dla każdego połączenia i natychmiastowym powrocie do pętli wywołującej aioble.advertise(), tak aby kolejny klient również mógł się połączyć:

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

Każde połączenie działa we własnym zadaniu. Baza danych GATT jest współdzielona – wszyscy klienci widzą te same usługi i charakterystyki – ale stan właściwy dla każdego połączenia żyje wewnątrz zadania. Powiadomienia trafiają do każdego zasubskrybowanego klienta, gdy write() jest wywoływana z send_update=True; ukierunkowane wysyłki, które powinny dotrzeć tylko do jednego klienta, używają notify() / indicate() z konkretnym argumentem DeviceConnection.

Utrzymuj rozgałęzienie niewielkie. Każde podtrzymywane połączenie kosztuje czas radia, pamięć RAM i miejsce w tabeli połączeń kontrolera, a kamera nie jest zaprojektowana jako koncentrator dla dziesiątek klientów. Dwa lub trzy urządzenia centralne (telefon, tablet, towarzyszący mikrokontroler) są w pełni osiągalne; projekty wymagające większej liczby należą do właściwej bramy BLE, a nie do kamery.

11.12.2. Urządzenie peryferyjne i centralne jednocześnie

Kamera może rozgłaszać własną usługę telefonowi, jednocześnie działając jako urządzenie centralne wobec urządzenia noszonego. aioble nie ma przełącznika „trybu” – pętla rozgłaszania oraz pętla skanowania i łączenia to po prostu niezależne korutyny:

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

Radio dzieli czas między dwie role – okno skanowania tutaj, impuls rozgłaszania tam, zdarzenie połączenia, gdy jedno z połączeń którejkolwiek strony jest aktywne. Przepustowość każdej roli spada, gdy obie są aktywne, ponieważ radio nie może dosłownie robić dwóch rzeczy naraz, ale w przypadku rozmów o niskiej przepustowości, do których BLE zostało zaprojektowane, koszt ten jest zwykle niezauważalny.

Dwie praktyczne rzeczy, o których warto pamiętać:

  • Obie role muszą znajdować się we własnej korutynie. Wywołanie aioble.scan() z wnętrza zadania obsługującego podłączone urządzenie centralne dla danego klienta działa, ale blokuje powiadomienia tego klienta, dopóki skanowanie się nie zakończy – zamiast tego uruchom skanowanie we własnym zadaniu.

  • W danym momencie działa tylko jedno skanowanie. Jeśli musisz skanować z dwóch różnych miejsc, współdziel iterator skanowania lub koordynuj dostęp; nie wchodź równolegle do dwóch menedżerów kontekstu aioble.scan().

11.12.3. Koordynowanie wielu połączeń z jednego zadania

Gdy kilka połączeń trzeba połączyć w jedną logiczną operację – na przykład kamera rozmawia z dwoma sensorami naraz i raportuje wynik dopiero po tym, jak oba odpowiedzą – standardowe prymitywy asyncio mają bezpośrednie zastosowanie. asyncio.gather() uruchamia korutyny dla poszczególnych połączeń współbieżnie i zwraca, gdy wszystkie się zakończą; asyncio.wait_for() dodaje termin.

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

Ten sam wzorzec, którego rozdział o asyncio (Asyncio) używa do obsługi sieci – korutyny BLE podłączają się do gather / wait_for / Event / Lock w taki sam sposób jak te oparte na TCP.

11.12.4. Gdy jedna rola kończy się w każdym cyklu, a druga nie

Cykl w kamerze zasilanej z baterii może wyglądać następująco:

  • Wybudzenie.

  • Jako urządzenie centralne, odczytaj świeże wartości ze sparowanego paska sensorowego.

  • Jako urządzenie peryferyjne, rozgłaszaj, aby telefon mógł pobrać pomiary z danego dnia.

  • Gdy obie role są bezczynne, wywołaj aioble.stop() i przejdź w stan uśpienia.

Sekwencjonowanie jest proste przy dwóch zadaniach i jednym 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