11.9. Vystupování jako periferie

Nejběžnějším BLE vzorem na straně kamery je vystupovat jako periferie – publikovat malou GATT databázi, oznamovat svou existenci, přijmout připojení z telefonu nebo doprovodného zařízení a streamovat hodnoty komukoli na druhém konci.

11.9.1. Sestavení GATT databáze

První věc, kterou periferie při spuštění udělá – ještě před zapnutím rádia – je sestavit databázi, kterou plánuje vystavit, vytvořit objekty pro každou službu a charakteristiku a poté celek zaregistrovat:

import aioble
import bluetooth

ENV_SERVICE = bluetooth.UUID(0x181A)              # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)                # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F)               # Humidity

env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
    env, TEMP_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
    env, HUMID_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)

aioble.register_services(env)

Každá aioble.Characteristic se ke své službě připojí jednoduše tak, že se vytvoří se službou jako prvním argumentem. Booleovské klíčové argumenty (read, write, write_no_response, notify, indicate) vybírají, které GATT operace bude klient smět provádět; předání False (výchozí) znamená, že příslušný bit vlastnosti není nastaven.

aioble.register_services() potvrdí sestavený strom do GATT serveru. Musí být zavolána jednou, předtím než začne jakýkoli aioble.advertise(); opětovné zavolání nahradí předchozí databázi.

11.9.2. Oznamování (advertising)

Jakmile je databáze připravena, oznamování je jediné volání korutiny, které čeká na připojení:

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

Klíčové argumenty se přímo mapují na pole užitečné zátěže oznamování. name je pole místního jména; services je seznam UUID služeb, které zařízení hostuje (skener na straně telefonu je může filtrovat); appearance je nápověda ze standardních 16bitových hodnot vzhledu, která umožňuje centrále zobrazit rozumnou ikonu. Data specifická pro výrobce se předávají přes manufacturer=(company_id, data_bytes).

Hrstka méně běžných klíčových slov pokrývá zbytek prostoru oznamovacích příznaků:

  • connectable=False – režim pouze pro vysílání (žádné připojení se nikdy nepřijme). Správná volba pro užitečnou zátěž ve stylu majáku (beacon).

  • limited_disc=True – použít příznak limited discoverable místo general discoverable; některé operační systémy s nimi v párovacím rozhraní zacházejí odlišně.

  • adv_data / resp_data – syrové bajty, pokud aplikace potřebuje plnou kontrolu nad rozvržením.

  • timeout_ms – vzdát se po pevně daném čase. Výchozí chování je oznamovat navždy.

Když se centrála připojí, aioble.advertise() vrátí výsledné aioble.DeviceConnection. Periferie v tomto okamžiku přestane oznamovat.

11.9.3. Obsluha jednoho klienta

Hlavní smyčka periferie typicky vypadá takto:

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        print("connected:", connection.device.addr_hex())
        async with connection:
            await connection.disconnected()
        print("disconnected; advertising again")

asyncio.run(serve())

async with connection zajistí automatické vyčištění při odpojení. disconnected() je korutina, která se pozastaví, dokud kterákoli strana neukončí připojení – čistý způsob, jak udržet periferii v obsluze, dokud centrála neodejde, a poté se vrátit zpět k oznamování dalšího kola.

11.9.4. Aktualizace charakteristiky

Periferie aktualizuje místní GATT databázi pomocí aioble.Characteristic.write()

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

Tím se změní hodnota, kterou by vrátilo další read od jakéhokoli klienta. Samo o sobě to neodešle novou hodnotu – přihlášený klient nic neuvidí, dokud klient buď neprovede dotaz, nebo dokud periferie neodešle explicitní oznámení.

Strana odeslání je jediné klíčové slovo ve stejném volání:

temp_char.write(temp_bytes, send_update=True)

send_update=True oznámí (nebo indikuje) každému klientovi, který je přihlášen k této charakteristice. Většina kódu ve stylu senzoru se nachází v úloze pro jednotlivé připojení, která ve smyčce čte senzor a zapisuje hodnotu s send_update=True zhruba každou sekundu:

async def stream_temperature(connection):
    while connection.is_connected():
        temp_char.write(encode_temperature(read_sensor()), send_update=True)
        await asyncio.sleep(1)

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

Pokud byste raději nasměrovali oznámení na jednoho konkrétního klienta než na celou přihlášenou množinu (řekněme soukromou odpověď na příkaz daného klienta v rámci připojení), aioble.Characteristic.notify() a indicate() přijímají argument DeviceConnection a volitelnou užitečnou zátěž.

11.9.5. Příjem zápisů

Opačný směr – klient zapisující do charakteristiky – se zpřístupní, když je charakteristika vytvořena s write=True nebo write_no_response=True. Periferie čeká na další zápis pomocí aioble.Characteristic.written()

cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)

async def handle_commands():
    while True:
        connection, data = await cmd_char.written()
        print("command from", connection.device.addr_hex(), "=", data)

Bez capture=True vrací written() pouze zapisující připojení; nová hodnota se nachází v záložním bufferu charakteristiky a aplikace ji načte pomocí read(). Pokud dorazí druhý zápis dříve, než aplikace přečetla první, druhá hodnota v bufferu přepíše první a původní hodnota je ztracena – written() aplikaci stále probudí, ale pouze jednou za „je tu něco nového“, nikoli jednou za každý zápis.

Klíčové slovo capture=True to napravuje. Každý příchozí zápis je přidán do fronty platné pro celý modul a written() vrací n-tici (connection, data) pro každý jednotlivý zápis – aplikační smyčka vidí každý z nich přesně jednou, v pořadí příchodu. Dva praktické důsledky:

  • Fronta je omezená a je sdílená napříč všemi charakteristikami zařízení s povoleným capture. Krátké dávky bezprostředně po sobě jdoucích zápisů jsou tolerovány; trvalé přetížení (zápisy přicházejí rychleji, než je aplikace stačí odebírat) tiše zahazuje nejstarší položky ve frontě a nárazový provoz na jedné charakteristice může vytlačit čekající položky z jiné.

  • Zvolte capture=True pro zápisy ve stylu příkazů, kde záleží na každé hodnotě. Ponechte jej vypnutý pro charakteristiky ve stylu stavu, kde je zajímavá pouze nejnovější hodnota.

Pokud má být čtení od klienta zodpovězeno kódem spuštěným na vyžádání spíše než statickou hodnotou, přepište on_read(). Metoda je volána synchronně, když přijde čtení; vraťte 0 pro povolení čtení (odešle se aktuální hodnota z write()) nebo nenulový ATT chybový kód pro jeho odmítnutí:

import time

_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000)            # at most once per second

class TempChar(aioble.Characteristic):
    _last_read_ms = 0

    def on_read(self, connection):
        now = time.ticks_ms()
        if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
            return _ATT_ERR_READ_NOT_PERMITTED
        self._last_read_ms = now
        self.write(encode_temperature(read_sensor()))
        return 0

temp_char = TempChar(env, TEMP_UUID, read=True)

Callback navzorkuje senzor a aktualizuje hodnotu charakteristiky těsně předtím, než GATT stack obslouží čtení, takže klient vždy vidí čerstvá data. Omezení rychlosti brání klientovi v tom, aby zatěžoval senzor rychleji, než jej lze navzorkovat – jakékoli čtení v rámci jednosekundové prodlevy je odmítnuto jako ATT chyba Read Not Permitted namísto zastaralé hodnoty.

11.9.5.1. Větší záložní buffery – BufferedCharacteristic

Záložní buffer pro běžnou Characteristic je široký 20 bajtů – praktický limit při výchozí MTU 23 bajtů. Klient, který do běžné charakteristiky zapíše více než to, dostane svou hodnotu oříznutou. Pro větší příchozí hodnoty nebo pro řazení bezprostředně po sobě jdoucích zápisů do fronty, které aplikační smyčka později doplní, deklarujte charakteristiku jako BufferedCharacteristic a velikost bufferu zvolte předem:

blob = aioble.BufferedCharacteristic(
    service, BLOB_UUID,
    max_len=512, append=True,
    write=True, capture=True,
)

async def receive_blob():
    while True:
        connection, chunk = await blob.written()
        handle_chunk(connection, chunk)

Dva přepínače ji odlišují od prosté Characteristic:

  • max_len je velikost záložního bufferu v bajtech. Zvolte ji tak, aby odpovídala největšímu jednotlivému zápisu, který se od klienta očekává (po vyjednání MTU).

  • append=True způsobí, že sekvenční zápisy se do bufferu připojují místo přepisování – užitečné pro příjem hodnoty, která dorazí napříč několika zápisy (části aktualizace firmware, řádky logu). S append=False se buffer chová jako běžná charakteristika, jen širší.

Všechny ostatní příznaky konstruktoru (read, write, notify, indicate, capture, initial) se beze změny předávají podkladové charakteristice.

11.9.6. Standardní služby a UUID přiřazená SIG

Držet se UUID z přiřazených čísel (0x180F pro Battery Service, 0x181A pro Environmental Sensing, 0x180D pro Heart Rate atd.) znamená, že obecné Bluetooth menu telefonu nebo jakákoli skenovací aplikace třetí strany dokáže identifikovat účel zařízení bez jakéhokoli vlastního klientského kódu. Rozvržení bajtů uvnitř každé standardní charakteristiky je rovněž pevně dáno specifikací – Battery Level (0x2A19) je jediný bajt 0..100; Temperature (0x2A6E) je little-endian sint16 v jednotkách 0,01 °C. Pro aplikace, které neodpovídají standardní službě, vygenerujte 128bitové UUID jednou a používejte jej napříč službami a charakteristikami zařízení.

Periferie, která publikuje pouze vlastní UUID, je stále v pořádku – jen potřebuje vlastní klientskou aplikaci, která o těchto UUID ví.

Poznámka

BLE hodnoty jsou všude little-endian – GATT specifikace, každá standardní charakteristika, každé pole oznamování. Vícebajtové celky jdou na sběrnici nejprve nejnižším bajtem. Prefix < ve formátovacích řetězcích struct je to, co chcete pro kódování/dekódování ("<h", "<H", "<I", …); použití výchozího nativního pořadí bajtů na little-endian MCU sice zatím funguje, ale explicitní vypsání < je bezpečným zvykem.

11.9.7. Rádio v pozadí všeho

Rádio je zapnuté v okamžiku, kdy se ho dotkne první korutina aioble. Dokud není připojena centrála, tráví periferie čas přepínáním mezi krátkými dávkami oznamování a spánkem; po připojení se řídí vyjednaným intervalem připojení. Periferie platí malou energetickou cenu za každé oznámení, takže volba interval_us u aioble.advertise() je nejpřímějším přepínačem, který periferie má pro vyvážení latence objevení vůči výdrži baterie.