11.9. Periféria szerepkörben

A kamera oldalán a leggyakoribb BLE minta az, hogy perifériaként működik – közzétesz egy kis GATT adatbázist, hirdeti a létezését, fogad egy kapcsolatot egy telefontól vagy egy társeszköztől, és a túloldalon lévőnek értékeket továbbít.

11.9.1. A GATT adatbázis felépítése

Az első dolog, amit egy periféria induláskor tesz – még a rádió bekapcsolása előtt – az, hogy felépíti azt az adatbázist, amelyet közzé kíván tenni, létrehozza az egyes szolgáltatásokhoz és karakterisztikákhoz tartozó objektumokat, majd regisztrálja az egészet:

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)

Minden egyes aioble.Characteristic egyszerűen úgy kapcsolódik a szolgáltatásához, hogy a szolgáltatást adjuk meg az első argumentumaként a létrehozáskor. A logikai kulcsszó-argumentumok (read, write, write_no_response, notify, indicate) választják ki, hogy a kliens mely GATT műveleteket végezheti el; a False megadása (ez az alapértelmezett) azt jelenti, hogy a tulajdonságbit nincs beállítva.

A aioble.register_services() rögzíti az összeállított fát a GATT szerverben. Egyszer kell meghívni, mielőtt bármilyen aioble.advertise() elindulna; ismételt meghívása felülírja a korábbi adatbázist.

11.9.2. Hirdetés

Amint az adatbázis a helyén van, a hirdetés egyetlen korutinhívás, amely egy kapcsolatra vár:

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

A kulcsszó-argumentumok közvetlenül leképeződnek a hirdetési hasznos teher mezőire. A name a helyi név mező; a services azon szolgáltatás UUID-k listája, amelyeket az eszköz kínál (egy telefonoldali szkenner ezek alapján szűrhet); az appearance egy tipp a szabványos 16 bites megjelenési értékekből, amely lehetővé teszi, hogy a központ értelmes ikont jelenítsen meg. A gyártóspecifikus adatok a manufacturer=(company_id, data_bytes) segítségével adhatók meg.

Néhány kevésbé gyakori kulcsszó lefedi a hirdetési jelzők hátralévő részét:

  • connectable=False – csak adásra szolgáló mód (kapcsolat soha nem kerül elfogadásra). Ez a helyes választás a jeladó (beacon) jellegű hasznos teherhez.

  • limited_disc=True – a korlátozottan felfedezhető jelzőt használja a általánosan felfedezhető helyett; egyes operációs rendszerek a kettőt eltérően kezelik a párosítási felületükön.

  • adv_data / resp_data – nyers bájtok, ha az alkalmazásnak teljes kontrollra van szüksége az elrendezés felett.

  • timeout_ms – adott idő után feladja. Az alapértelmezett a végtelen ideig tartó hirdetés.

Amikor egy központ csatlakozik, a aioble.advertise() visszaadja a létrejött aioble.DeviceConnection objektumot. A periféria ezen a ponton abbahagyja a hirdetést.

11.9.3. Egyetlen kliens kiszolgálása

Egy periféria fő ciklusa jellemzően így néz ki:

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

Az async with connection automatikussá teszi a kapcsolatbontás utáni takarítást. A disconnected() egy korutin, amely felfüggesztődik mindaddig, amíg valamelyik fél le nem zárja a kapcsolatot – tiszta módja annak, hogy a periféria addig szolgáljon ki, amíg a központ el nem távozik, majd visszatérjen a következő kör hirdetéséhez.

11.9.4. Egy karakterisztika frissítése

A periféria a aioble.Characteristic.write() segítségével frissíti a helyi GATT adatbázist:

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

Ez megváltoztatja azt az értéket, amelyet bármely kliens következő read művelete visszaadna. Önmagában nem tolja ki az új értéket – egy feliratkozott kliens semmit sem fog látni mindaddig, amíg vagy a kliens le nem kérdez, vagy a periféria explicit értesítést nem küld.

A kitolási oldal egyetlen kulcsszó ugyanazon a híváson:

temp_char.write(temp_bytes, send_update=True)

A send_update=True értesíti (vagy jelzi) minden olyan klienst, amely feliratkozott erre a karakterisztikára. A legtöbb érzékelő jellegű kód egy kapcsolatonkénti feladatban él, amely ciklusban olvassa az érzékelőt, és nagyjából másodpercenként kiírja az értéket a send_update=True használatával:

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

Ha inkább egy adott klienshez szeretnél irányítani egy értesítést a teljes feliratkozott halmaz helyett (mondjuk egy kapcsolat-privát választ az adott kliens parancsára), a aioble.Characteristic.notify() és a indicate() egy DeviceConnection argumentumot és egy opcionális hasznos terhet fogad.

11.9.5. Írások fogadása

A másik irány – amikor egy kliens egy karakterisztikába ír – akkor válik elérhetővé, ha a karakterisztikát a write=True vagy write_no_response=True opcióval hozzuk létre. A periféria a aioble.Characteristic.written() segítségével várja a következő írást:

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)

A capture=True nélkül a written() csak az író kapcsolatot adja vissza; az új érték a karakterisztika háttérpufferében található, és az alkalmazás a read() segítségével szerzi meg. Ha egy második írás érkezik, mielőtt az alkalmazás kiolvasta volna az elsőt, a második érték felülírja az elsőt a pufferben, és az eredeti érték elveszik – a written() ennek ellenére felébreszti az alkalmazást, de csak egyszer minden „van valami új” eseményre, nem egyszer minden írásra.

A capture=True kulcsszó ezt orvosolja. Minden bejövő írás egy modulszintű sorhoz fűződik hozzá, és a written() minden egyes írásra egy (connection, data) rendezett párt ad vissza – az alkalmazás ciklusa mindegyiket pontosan egyszer látja, érkezési sorrendben. Két gyakorlati következmény:

  • A sor korlátos, és az eszköz minden capture-engedélyezett karakterisztikája között megosztott. Az egymás utáni írások rövid sorozatai elviselhetők; tartós túlcsordulás esetén (amikor az írások gyorsabban érkeznek, mint ahogy az alkalmazás kiüríti őket) a rendszer csendben eldobja a legrégebbi sorba állított bejegyzéseket, és az egyik karakterisztikán érkező lökésszerű forgalom kiszoríthatja egy másik függőben lévő bejegyzéseit.

  • Válaszd a capture=True opciót olyan parancs jellegű írásokhoz, ahol minden érték számít. Hagyd kikapcsolva az állapot jellegű karakterisztikáknál, ahol csak a legutóbbi érték érdekes.

Ha egy kliens olvasását egy statikus érték helyett igény szerint futó kódnak kell megválaszolnia, írd felül a on_read() metódust. A metódus szinkron módon hívódik meg, amikor egy olvasás érkezik; adj vissza 0-t az olvasás engedélyezéséhez (a write() aktuális értéke kerül elküldésre), vagy egy nem nulla ATT hibakódot az elutasításához:

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)

A visszahívás mintát vesz az érzékelőből, és frissíti a karakterisztika értékét közvetlenül azelőtt, hogy a GATT verem kiszolgálná az olvasást, így a kliens mindig friss adatot lát. A sebességkorlát megakadályozza, hogy egy kliens gyorsabban ostromolja az érzékelőt, mint ahogy az mintavételezhető – bármely olvasás az egy másodperces lehűlési időn belül egy Read Not Permitted ATT hibaként pattan vissza, nem pedig egy elavult értékként.

11.9.5.1. Nagyobb háttérpufferek – BufferedCharacteristic

Egy hagyományos Characteristic háttérpuffere 20 bájt széles – ez a gyakorlati határ az alapértelmezett 23 bájtos MTU mellett. Egy kliens, amely ennél többet ír egy hagyományos karakterisztikába, csonkolt értéket kap. Nagyobb bejövő értékekhez vagy egymás utáni írások sorba állításához, amelyeket az alkalmazás ciklusa később dolgoz fel, a karakterisztikát BufferedCharacteristic típusúként deklaráld, és előre válaszd meg a pufferméretet:

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)

Két szabályozó különbözteti meg egy egyszerű Characteristic típustól:

  • A max_len a háttérpuffer mérete bájtban. Úgy válaszd meg, hogy megfeleljen a legnagyobb egyetlen írásnak, amelyet a klienstől várható (az MTU egyeztetés után).

  • Az append=True hatására az egymást követő írások a pufferbe hozzáfűződnek felülírás helyett – hasznos olyan érték fogadásához, amely több írásban érkezik (firmware-frissítési darabok, naplósorok). Az append=False esetén a puffer normál karakterisztikaként viselkedik, csak szélesebb.

Az összes többi konstruktor jelző (read, write, notify, indicate, capture, initial) változatlanul továbbítódik az alatta lévő karakterisztikának.

11.9.6. Szabványos szolgáltatások és a SIG által kiosztott UUID-k

A kiosztott számokhoz tartozó UUID-knál maradva (0x180F az akkumulátor-szolgáltatáshoz, 0x181A a környezeti érzékeléshez, 0x180D a pulzusszámhoz, és így tovább) egy telefon általános Bluetooth menüje vagy bármely külső szkennerprogram egyedi kliens kód nélkül is azonosítani tudja az eszköz célját. Az egyes szabványos karakterisztikákon belüli bájtelrendezést is rögzíti a specifikáció – az akkumulátorszint (0x2A19) egyetlen bájt 0..100 között; a hőmérséklet (0x2A6E) little-endian sint16 0,01 fok-C egységekben. Azoknál az alkalmazásoknál, amelyek nem illeszkednek egy szabványos szolgáltatáshoz, generálj egyszer egy 128 bites UUID-t, és használd azt az eszköz szolgáltatásaiban és karakterisztikáiban.

Egy olyan periféria, amely csak egyedi UUID-kat tesz közzé, továbbra is rendben van – csupán egy olyan egyedi kliensalkalmazásra van szüksége, amely ismeri ezeket az UUID-kat.

Megjegyzés

A BLE értékek mindenhol little-endian kódolásúak – a GATT specifikáció, minden szabványos karakterisztika, minden hirdetési mező. A többbájtos egész számok az alacsony bájttal előre kerülnek a vonalra. A struct formátumsztringekben szereplő < előtag az, amire a kódoláshoz/dekódoláshoz szükséged van ("<h", "<H", "<I", …); az alapértelmezett natív bájtsorrend használata egy little-endian MCU-n pillanatnyilag történetesen működik, de a < kiírása a biztonságos szokás.

11.9.7. A mindezek mögött álló rádió

A rádió attól a pillanattól kezdve be van kapcsolva, amint az első aioble korutin hozzányúl. Amíg egy központ nem csatlakozik, a periféria az idejét rövid hirdetési lökések és alvás közötti váltogatással tölti; egy kapcsolat után az egyeztetett kapcsolati intervallumot követi. A periféria hirdetésenként kis energiaköltséggel jár, így a aioble.advertise() interval_us paraméterének megválasztása a legközvetlenebb szabályozó, amellyel egy periféria a felfedezési késleltetést az akkumulátor-élettartam ellenében cserélheti.