11.9. Funcționarea ca periferic

Cel mai răspândit tipar BLE de partea camerei este acela de a funcționa ca periferic – de a publica o mică bază de date GATT, de a-și anunța existența, de a accepta o conexiune de la un telefon sau de la un dispozitiv însoțitor și de a transmite valori către cine se află la celălalt capăt.

11.9.1. Construirea bazei de date GATT

Primul lucru pe care îl face un periferic la pornire – chiar înainte de a porni radioul – este să construiască baza de date pe care intenționează să o expună, să creeze obiecte pentru fiecare serviciu și caracteristică, apoi să le înregistreze pe toate:

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)

Fiecare aioble.Characteristic este atașată serviciului său pur și simplu prin construirea ei cu serviciul ca prim argument. Argumentele de tip cuvânt-cheie booleene (read, write, write_no_response, notify, indicate) selectează ce operații GATT îi vor fi permise clientului; transmiterea valorii False (valoarea implicită) înseamnă că bitul proprietății nu este setat.

aioble.register_services() înregistrează arborele asamblat la serverul GATT. Trebuie apelată o singură dată, înainte ca vreun aioble.advertise() să pornească; apelarea ei din nou înlocuiește baza de date anterioară.

11.9.2. Anunțarea (advertising)

Odată ce baza de date este pregătită, anunțarea constă într-un singur apel de corutină care așteaptă o conexiune:

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

Argumentele de tip cuvânt-cheie corespund direct câmpurilor din încărcătura utilă de anunțare. name este câmpul cu numele local; services este lista de UUID-uri ale serviciilor găzduite de dispozitiv (un scaner de partea telefonului poate filtra după acestea); appearance este un indiciu din valorile standard de aspect pe 16 biți care permite dispozitivului central să afișeze o pictogramă potrivită. Datele specifice producătorului se transmit prin manufacturer=(company_id, data_bytes).

Câteva cuvinte-cheie mai puțin frecvente acoperă restul spațiului de flag-uri de anunțare:

  • connectable=False – mod exclusiv de difuzare (nicio conexiune nu este acceptată vreodată). Alegerea potrivită pentru încărcături utile de tip beacon.

  • limited_disc=True – folosește flag-ul limited discoverable în loc de general discoverable; unele sisteme de operare tratează diferit cele două opțiuni în interfața lor de împerechere.

  • adv_data / resp_data – octeți bruți, dacă aplicația are nevoie de control complet asupra dispunerii.

  • timeout_ms – renunță după un timp fix. Comportamentul implicit este de a anunța la nesfârșit.

Atunci când un dispozitiv central se conectează, aioble.advertise() returnează obiectul aioble.DeviceConnection rezultat. Perifericul oprește anunțarea în acest moment.

11.9.3. Deservirea unui singur client

Bucla principală a unui periferic arată de obicei astfel:

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 face automată curățarea la deconectare. disconnected() este o corutină care se suspendă până când oricare dintre părți încheie conexiunea – o modalitate curată de a menține perifericul în funcțiune până când dispozitivul central pleacă, apoi de a reveni la anunțare pentru următoarea rundă.

11.9.4. Actualizarea unei caracteristici

Perifericul actualizează baza de date GATT locală cu aioble.Characteristic.write()

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

Aceasta modifică valoarea pe care ar returna-o următoarea operație read de la orice client. În sine, nu transmite noua valoare – un client abonat nu va vedea nimic până când fie clientul nu interoghează, fie perifericul nu trimite o notificare explicită.

Partea de transmitere se reduce la un singur cuvânt-cheie în același apel:

temp_char.write(temp_bytes, send_update=True)

send_update=True notifică (sau indică) fiecare client care s-a abonat la această caracteristică. Cea mai mare parte a codului de tip senzor se află într-o sarcină per conexiune care, într-o buclă, citește senzorul și scrie valoarea cu send_update=True aproximativ o dată pe secundă:

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

Dacă preferi să direcționezi o notificare către un anumit client în loc de întregul set de abonați (de exemplu, un răspuns privat al conexiunii la comanda acelui client), aioble.Characteristic.notify() și indicate() acceptă un argument DeviceConnection și o încărcătură utilă opțională.

11.9.5. Recepționarea scrierilor

Direcția cealaltă – un client care scrie într-o caracteristică – devine disponibilă atunci când caracteristica este construită cu write=True sau write_no_response=True. Perifericul așteaptă următoarea scriere cu 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)

Fără capture=True, written() returnează doar conexiunea care scrie; noua valoare se află în tamponul de stocare al caracteristicii, iar aplicația o preia cu read(). Dacă sosește o a doua scriere înainte ca aplicația să fi citit-o pe prima, a doua valoare suprascrie prima în tampon, iar valoarea originală se pierde – written() tot trezește aplicația, dar doar o dată per „există ceva nou”, nu o dată per scriere.

Cuvântul-cheie capture=True rezolvă acest lucru. Fiecare scriere primită este adăugată într-o coadă la nivel de modul, iar written() returnează un tuplu (connection, data) pentru fiecare scriere individuală – bucla aplicației vede fiecare scriere exact o dată, în ordinea sosirii. Există două consecințe practice:

  • Coada este mărginită și este partajată între toate caracteristicile cu captură activată de pe dispozitiv. Rafalele scurte de scrieri succesive sunt tolerate; depășirea susținută (scrieri care sosesc mai repede decât le golește aplicația) elimină în mod silențios intrările cele mai vechi din coadă, iar traficul în rafale pe o caracteristică poate evacua intrări în așteptare de la alta.

  • Alege capture=True pentru scrieri de tip comandă, unde fiecare valoare contează. Lasă-l dezactivat pentru caracteristici de tip stare, unde doar cea mai recentă valoare prezintă interes.

Dacă o citire de la client ar trebui să primească răspuns de la cod care rulează la cerere, mai degrabă decât o valoare statică, suprascrie on_read(). Metoda este apelată sincron atunci când sosește o citire; returnează 0 pentru a permite citirea (va fi trimisă valoarea curentă de la write()) sau un cod de eroare ATT diferit de zero pentru a o respinge:

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)

Funcția de retroapelare (callback) eșantionează senzorul și actualizează valoarea caracteristicii chiar înainte ca stiva GATT să deservească citirea, astfel încât clientul vede întotdeauna date proaspete. Limita de rată împiedică un client să solicite senzorul mai repede decât poate fi eșantionat – orice citire în intervalul de răgaz de o secundă este respinsă cu o eroare ATT Read Not Permitted în loc de o valoare învechită.

11.9.5.1. Tampoane de stocare mai mari – BufferedCharacteristic

Tamponul de stocare pentru o caracteristică Characteristic obișnuită are o lățime de 20 de octeți – limita practică la MTU-ul implicit de 23 de octeți. Un client care scrie mai mult de atât într-o caracteristică obișnuită își vede valoarea trunchiată. Pentru valori primite mai mari sau pentru punerea în coadă a scrierilor succesive pe care bucla aplicației le va prelua mai târziu, declară caracteristica drept BufferedCharacteristic și alege dimensiunea tamponului din start:

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)

Două opțiuni o disting de o caracteristică Characteristic simplă:

  • max_len este dimensiunea în octeți a tamponului de stocare. Alege-o pentru a se potrivi cu cea mai mare scriere individuală pe care se așteaptă să o facă clientul (după negocierea MTU).

  • append=True face ca scrierile succesive să se adauge în tampon în loc să suprascrie – util pentru recepționarea unei valori care sosește prin mai multe scrieri (fragmente de actualizare a firmware-ului, linii de jurnal). Cu append=False, tamponul se comportă ca o caracteristică normală, doar mai larg.

Toate celelalte flag-uri ale constructorului (read, write, notify, indicate, capture, initial) sunt transmise neschimbate către caracteristica subiacentă.

11.9.6. Serviciile standard și UUID-urile atribuite de SIG

Respectarea UUID-urilor cu numere atribuite (0x180F pentru Battery Service, 0x181A pentru Environmental Sensing, 0x180D pentru Heart Rate și așa mai departe) înseamnă că meniul Bluetooth generic al unui telefon sau orice aplicație terță de scanare poate identifica scopul dispozitivului fără niciun cod client personalizat. Și dispunerea octeților din interiorul fiecărei caracteristici standard este fixată de specificație – Battery Level (0x2A19) este un singur octet 0..100; Temperature (0x2A6E) este sint16 little-endian în unități de 0,01 grade C. Pentru aplicații care nu se încadrează într-un serviciu standard, generează un UUID pe 128 de biți o singură dată și folosește-l în toate serviciile și caracteristicile dispozitivului.

Un periferic care publică doar UUID-uri personalizate este perfect în regulă – are nevoie doar de o aplicație client personalizată care cunoaște acele UUID-uri.

Notă

Valorile BLE sunt little-endian peste tot – specificația GATT, fiecare caracteristică standard, fiecare câmp de anunțare. Numerele întregi pe mai mulți octeți sunt transmise pe fir cu octetul cel mai puțin semnificativ primul. Prefixul < din șirurile de format struct este ceea ce ai nevoie pentru codificare/decodificare ("<h", "<H", "<I", …); folosirea ordinii native implicite a octeților pe un MCU little-endian se întâmplă să funcționeze deocamdată, dar specificarea explicită a lui < este obiceiul sigur.

11.9.7. Radioul care stă în spatele tuturor

Radioul este pornit din momentul în care prima corutină aioble îl accesează. Până când un dispozitiv central este conectat, perifericul își petrece timpul comutând între scurte rafale de anunțare și repaus; după o conexiune, urmează intervalul de conexiune negociat. Perifericul plătește un mic cost energetic per anunț, așa că alegerea valorii interval_us în aioble.advertise() este cea mai directă opțiune pe care o are un periferic pentru a echilibra latența descoperirii cu durata de viață a bateriei.