11.9. Att agera som kringutrustning

Det vanligaste BLE-mönstret på kamerasidan är att agera som kringutrustning – publicera en liten GATT-databas, annonsera sin existens, acceptera en anslutning från en telefon eller en följeslagarenhet och strömma värden till den som finns i andra änden.

11.9.1. Att bygga GATT-databasen

Det första en kringutrustning gör vid uppstart – redan innan radion slås på – är att bygga den databas den planerar att exponera, konstruera objekt för varje tjänst och karakteristik och sedan registrera alltihop:

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)

Varje aioble.Characteristic kopplas till sin tjänst helt enkelt genom att konstrueras med tjänsten som första argument. De booleska nyckelordsargumenten (read, write, write_no_response, notify, indicate) väljer vilka GATT-operationer klienten tillåts utföra; att skicka False (standardvärdet) innebär att egenskapsbiten inte sätts.

aioble.register_services() registrerar det sammansatta trädet i GATT-servern. Det måste anropas en gång, innan någon aioble.advertise() startar; att anropa det igen ersätter den tidigare databasen.

11.9.2. Annonsering

När databasen väl är på plats är annonsering ett enda coroutine-anrop som väntar på en anslutning:

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

Nyckelordsargumenten mappar direkt mot fälten i annonseringsnyttolasten. name är fältet för lokalt namn; services är listan över de tjänst-UUID:er enheten erbjuder (en skanner på telefonsidan kan filtrera på dessa); appearance är en ledtråd från de standardiserade 16-bitars utseendevärdena som låter den centrala enheten visa en lämplig ikon. Tillverkarspecifik data skickas in via manufacturer=(company_id, data_bytes).

En handfull mindre vanliga nyckelord täcker resten av utrymmet för annonseringsflaggor:

  • connectable=False – enbart sändningsläge (ingen anslutning accepteras någonsin). Det rätta valet för nyttolaster i beacon-stil.

  • limited_disc=True – använd flaggan limited discoverable i stället för general discoverable; vissa operativsystem behandlar de två olika i sitt parkopplingsgränssnitt.

  • adv_data / resp_data – råa byte om applikationen behöver full kontroll över layouten.

  • timeout_ms – ge upp efter en fast tid. Standard är att annonsera för alltid.

När en central enhet ansluter returnerar aioble.advertise() den resulterande aioble.DeviceConnection. Kringutrustningen slutar annonsera vid denna punkt.

11.9.3. Att betjäna en klient

Huvudslingan i en kringutrustning ser vanligtvis ut så här:

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 gör frånkopplingsstädningen automatisk. disconnected() är en coroutine som pausar tills endera sidan avslutar anslutningen – ett rent sätt att hålla kringutrustningen i drift tills den centrala enheten försvinner, och sedan gå tillbaka till att annonsera nästa varv.

11.9.4. Att uppdatera en karakteristik

Kringutrustningen uppdaterar den lokala GATT-databasen med aioble.Characteristic.write()

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

Det ändrar det värde som nästa read från en valfri klient skulle returnera. I sig själv skickar det inte ut det nya värdet – en prenumererande klient ser ingenting förrän antingen klienten pollar eller kringutrustningen skickar en explicit notifiering.

Push-sidan är ett enda nyckelord på samma anrop:

temp_char.write(temp_bytes, send_update=True)

send_update=True notifierar (eller indikerar) varje klient som har prenumererat på denna karakteristik. Den mesta koden i sensorstil bor i en uppgift per anslutning som i en slinga läser sensorn och skriver värdet med send_update=True ungefär en gång i sekunden:

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

Om du hellre vill rikta en notifiering till en specifik klient i stället för hela den prenumererande mängden (säg ett anslutningsprivat svar på den klientens kommando), tar aioble.Characteristic.notify() och indicate() ett DeviceConnection-argument och en valfri nyttolast.

11.9.5. Att ta emot skrivningar

Den andra riktningen – en klient som skriver till en karakteristik – blir tillgänglig när karakteristiken konstrueras med write=True eller write_no_response=True. Kringutrustningen inväntar nästa skrivning med 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)

Utan capture=True returnerar written() bara den skrivande anslutningen; det nya värdet ligger i karakteristikens bakomliggande buffert och applikationen hämtar det med read(). Om en andra skrivning anländer innan applikationen har läst den första, skriver det andra värdet över det första i bufferten och det ursprungliga värdet går förlorat – written() väcker fortfarande applikationen, men bara en gång per ”det finns något nytt”, inte en gång per skrivning.

Nyckelordet capture=True åtgärdar det. Varje inkommande skrivning läggs till i en modulomfattande kö, och written() returnerar en (connection, data)-tupel för varje enskild skrivning – applikationsslingan ser var och en exakt en gång, i ankomstordning. Två praktiska konsekvenser:

  • Kön är begränsad och delas mellan varje capture-aktiverad karakteristik på enheten. Korta skurar av skrivningar i följd tolereras; ihållande överbelastning (skrivningar som anländer snabbare än applikationen tömmer dem) släpper tyst de äldsta köade posterna, och skurartrafik på en karakteristik kan tränga undan väntande poster från en annan.

  • Välj capture=True för kommandostilade skrivningar där varje värde har betydelse. Lämna det avstängt för tillståndsstilade karakteristiker där det senaste värdet är det enda som är av intresse.

Om en läsning från klienten ska besvaras av kod som körs på begäran i stället för av ett statiskt värde, åsidosätt on_read(). Metoden anropas synkront när en läsning kommer in; returnera 0 för att tillåta läsningen (det aktuella värdet från write() skickas), eller en ATT-felkod som inte är noll för att avvisa den:

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)

Återanropet samplar sensorn och uppdaterar karakteristikens värde precis innan GATT-stacken betjänar läsningen, så att klienten alltid ser färska data. Hastighetsbegränsningen hindrar en klient från att hamra på sensorn snabbare än den kan samplas – varje läsning inom nedkylningsperioden på en sekund studsas tillbaka som ett Read Not Permitted-ATT-fel i stället för ett inaktuellt värde.

11.9.5.1. Större bakomliggande buffertar – BufferedCharacteristic

Den bakomliggande bufferten för en vanlig Characteristic är 20 byte bred – den praktiska gränsen vid standard-MTU på 23 byte. En klient som skriver mer än så i en vanlig karakteristik får sitt värde avkortat. För större inkommande värden eller för att köa skrivningar i följd som applikationsslingan hinner ifatt senare, deklarera karakteristiken som BufferedCharacteristic och välj buffertstorleken i förväg:

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)

Två reglage skiljer den från en vanlig Characteristic:

  • max_len är storleken på den bakomliggande bufferten i byte. Välj den så att den matchar den största enskilda skrivning som klienten förväntas göra (efter MTU-förhandling).

  • append=True gör att sekventiella skrivningar läggs till i bufferten i stället för att skriva över – användbart för att ta emot ett värde som anländer över flera skrivningar (delar av firmware-uppdateringar, loggrader). Med append=False beter sig bufferten som en normal karakteristik, bara bredare.

Alla andra konstruktorflaggor (read, write, notify, indicate, capture, initial) vidarebefordras oförändrade till den underliggande karakteristiken.

11.9.6. Standardtjänster och de SIG-tilldelade UUID:erna

Att hålla sig till de tilldelade nummerens UUID:er (0x180F för Battery Service, 0x181A för Environmental Sensing, 0x180D för Heart Rate, och så vidare) innebär att en telefons generiska Bluetooth-meny eller en valfri tredjepartsskanner kan identifiera enhetens syfte utan någon anpassad klientkod. Bytelayouten inuti varje standardkarakteristik är också fastställd av specifikationen – Battery Level (0x2A19) är en enda byte 0..100; Temperature (0x2A6E) är little-endian sint16 i enheter om 0,01 grader Celsius. För applikationer som inte passar in i en standardtjänst, generera ett 128-bitars UUID en gång och använd det genomgående för enhetens tjänster och karakteristiker.

En kringutrustning som bara publicerar anpassade UUID:er fungerar fortfarande utmärkt – den behöver bara en anpassad klientapp som känner till dessa UUID:er.

Anteckning

BLE-värden är little-endian överallt – GATT-specifikationen, varje standardkarakteristik, varje annonseringsfält. Heltal med flera byte läggs på tråden med den låga byten först. Prefixet < i struct-formatsträngar är vad du vill ha för kodning/avkodning ("<h", "<H", "<I", …); att använda den förvalda inhemska byteordningen på en little-endian-MCU råkar fungera för tillfället, men att stava ut < är den säkra vanan.

11.9.7. Radion bakom alltihop

Radion är påslagen i samma ögonblick som den första aioble-coroutinen rör vid den. Tills en central enhet är ansluten tillbringar kringutrustningen sin tid med att växla mellan korta annonseringsskurar och vila; efter en anslutning följer den det förhandlade anslutningsintervallet. Kringutrustningen betalar en liten effektkostnad per annonsering, så valet av interval_usaioble.advertise() är det mest direkta reglage en kringutrustning har för att väga upptäcktslatens mot batteritid.