11.10. Agire come central#

L’altro lato della conversazione e il central – il dispositivo che esegue la scansione delle periferiche in advertising, ne sceglie una con cui parlare, apre una connessione, percorre il database GATT remoto e legge o si iscrive alle caratteristiche su di esso. Una camera che raccoglie letture da un sensore indossabile, ascolta un beacon o parla con un microcontrollore companion e un central.

Il pattern del central in aioble si snoda attraverso quattro fasi: scansione, connessione, scoperta, operazione.

11.10.1. Scansione#

aioble.scan() restituisce un context manager asincrono che funge anche da iteratore asincrono sui dispositivi scoperti. L’uso tipico e eseguire la scansione finche non compare un dispositivo di interesse, poi uscire dall’iterazione:

import aioble
import asyncio
import bluetooth

HR_SERVICE = bluetooth.UUID(0x180D)

async def find_heart_rate():
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if HR_SERVICE in result.services():
                return result.device
    return None

duration_ms=5000 limita la durata della scansione; duration_ms=0 esegue la scansione all’infinito (finche il context manager non esce). active=True richiede le scan response, che raddoppiano la dimensione del payload per dispositivo al costo di una piccola trasmissione aggiuntiva da entrambi i lati. I restanti argomenti keyword interval_us / window_us regolano il duty cycle radio dello scanner stesso e raramente vengono modificati rispetto ai valori predefiniti.

Ogni aioble.ScanResult espone l’indirizzo del dispositivo, l’ultimo RSSI, i byte grezzi di advertising e di scan response, e helper che effettuano il parsing dei campi standard:

  • result.device – un aioble.Device pronto per chiamarvi connect().

  • result.rssi – indicatore di intensita del segnale ricevuto in dBm, utile per la logica «scegli il piu vicino».

  • result.name() – la stringa del nome locale, oppure None se non viene pubblicizzata.

  • result.services() – un generatore di bluetooth.UUID per ogni servizio che il dispositivo pubblicizza.

  • result.manufacturer() – un generatore di tuple (company_id, data) per i campi specifici del produttore.

  • result.connectable – indica se l’advertisement piu recente era connettibile.

Lo stesso ScanResult viene ri-emesso man mano che arrivano nuovi dati di advertising per lo stesso dispositivo, cosi che un listener passivo che vuole semplicemente tracciare i dispositivi a tempo indefinito possa eseguire l’iteratore asincrono all’infinito e smistare a ogni evento.

11.10.2. Connessione#

Una volta identificato un dispositivo target, aprire una connessione e un singolo await

async def talk_to(device):
    connection = await device.connect()           # 10 s timeout
    async with connection:
        # ... do GATT work ...
        pass

aioble.Device.connect() accetta timeout_ms (quanto attendere che la connessione si stabilisca; predefinito 10 s) e min_conn_interval_us / max_conn_interval_us (l’intervallo richiesto di connection interval da Connessioni).

11.10.2.1. Riconnettersi a un peer noto senza scansione#

Una volta che esiste un bond con un peer, l’indirizzo e gia noto e un altro giro di scansione-e-scelta e tempo radio sprecato. Costruisci direttamente un aioble.Device con l’indirizzo salvato e salta direttamente a connect()

import aioble

KITCHEN_CAM = aioble.Device(aioble.ADDR_PUBLIC,
                            "aa:bb:cc:dd:ee:ff")

async def talk_to_kitchen():
    async with await KITCHEN_CAM.connect() as connection:
        # ... GATT work ...
        pass

Il primo argomento e uno tra aioble.ADDR_PUBLIC (l’indirizzo di fabbrica di un controller) o aioble.ADDR_RANDOM (un indirizzo privato statico generato o risolvibile); il secondo e o un valore bytes di sei byte o una stringa esadecimale separata da due punti. Gli attributi addr_type e addr di qualsiasi Device (ad esempio uno ottenuto in precedenza da un ScanResult) possono essere persistiti e reimmessi qui.

La aioble.DeviceConnection restituita e cio a cui si appende il resto del lavoro del central. async with garantisce che la connessione venga chiusa all’uscita dal blocco – in caso di successo, di cancellazione o di qualsiasi eccezione, inclusa aioble.DeviceDisconnectedError dovuta alla scomparsa del peer.

Se il central ha bisogno di un valore di caratteristica piu grande di quanto consenta l’MTU predefinito di 23 byte, questo e il punto in cui negoziarlo:

await connection.exchange_mtu(512)

(exchange_mtu() restituisce l’MTU effettivamente negoziato, che e il minimo tra il valore richiesto e quanto il peer supporta.)

11.10.3. Scoperta#

La scoperta percorre il database GATT remoto per trovare i servizi e le caratteristiche tramite i loro UUID. Ne esistono due varianti: mirata (conosci l’UUID e vuoi una cosa specifica) ed esaustiva (vuoi tutto).

Mirata – il caso comune:

service = await connection.service(HR_SERVICE)
if service is None:
    return                                        # no such service

char = await service.characteristic(HR_MEASUREMENT)
if char is None:
    return                                        # no such characteristic

aioble.DeviceConnection.service() e aioble.ClientService.characteristic() accettano ciascuna un bluetooth.UUID e restituiscono l’oggetto corrispondente (oppure None). Entrambe hanno un keyword timeout_ms per scoperta che ha come predefinito 2 s.

Esaustiva:

async for service in connection.services():
    print("service:", service.uuid)
    async for char in service.characteristics():
        print("  characteristic:", char.uuid, "properties:", hex(char.properties))

Questo e cio che fanno le app generiche di esplorazione Bluetooth – utile per lo sviluppo, meno per il codice di produzione che conosce gia quali UUID si aspetta.

11.10.3.1. Ispezionare cosa supporta una caratteristica#

La scoperta restituisce la bitmask delle proprieta GATT che il peer ha pubblicizzato per ciascuna caratteristica come properties. I bit sono quelli definiti dal GATT – read (0x02), write-without-response (0x04), write (0x08), notify (0x10), indicate (0x20) e affini. Ispezionare la bitmask prima di emettere un’operazione permette a un client generico di adattarsi a caratteristiche le cui capacita non conosce in anticipo:

_PROP_READ = const(0x02)
_PROP_NOTIFY = const(0x10)

char = await service.characteristic(STATUS_UUID)
if char.properties & _PROP_NOTIFY:
    await char.subscribe(notify=True)
    value = await char.notified()
elif char.properties & _PROP_READ:
    value = await char.read()
else:
    value = None                                  # nothing the client can do

Il codice di produzione che gia conosce il profilo GATT del peer di solito non ne ha bisogno – gli UUID erano documentati fin dall’inizio. I client generici / esplorativi (una pagina di impostazioni che percorre un dispositivo sconosciuto, un host di plugin) vi si appoggiano.

11.10.4. Operazione#

Una volta che il central detiene una ClientCharacteristic, ogni operazione GATT e una singola chiamata a coroutine:

  • Read. Emetti una lettura GATT e ottieni indietro il valore:

    value = await char.read()
    print("value:", value)
    

    Le letture lunghe (valori piu grandi dell’MTU) vengono gestite in modo trasparente.

  • Write. Invia un nuovo valore al server:

    await char.write(b"\\x01")
    

    response=True attende una write-response e solleva aioble.GattError se il server rifiuta la scrittura. response=False e una write-without-response: fire-and-forget. response=None (il predefinito) sceglie automaticamente in base a cio che il peer ha pubblicizzato.

  • Subscribe. Abilita le notifiche o le indicazioni scrivendo nel CCCD della caratteristica:

    await char.subscribe(notify=True)
    

    Dopo che questo ritorna, il central puo attendere i push in arrivo.

  • Notified / indicated. Attendi il prossimo push dal server:

    while True:
        data = await char.notified()
        print("push:", data)
    

    timeout_ms=None (il predefinito) attende all’infinito; passa un intero in millisecondi per rinunciare dopo un certo tempo.

Mettere insieme i quattro produce il programma central canonico «connetti, iscriviti, trasmetti»:

async def stream_heart_rate():
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if HR_SERVICE in result.services():
                device = result.device
                break
        else:
            return

    async with await device.connect() as connection:
        service = await connection.service(HR_SERVICE)
        char = await service.characteristic(HR_MEASUREMENT)
        await char.subscribe(notify=True)
        while connection.is_connected():
            data = await char.notified()
            print("hr push:", data)

asyncio.run(stream_heart_rate())

L’intera cosa e lunga circa una dozzina di righe e copre il flusso da «nessun Bluetooth in esecuzione» a «dati in streaming dal vivo». L’iteratore di scansione corrisponde al pattern broadcaster/observer, connect apre la connessione GAP, service / characteristic percorre l’albero GATT, subscribe scrive il CCCD e notified attende i push.

11.10.5. Disconnessioni e riconnessione#

Qualsiasi cosa accada al collegamento radio affiora nella coroutine che era in attesa su di esso. aioble.DeviceDisconnectedError e il segnale che il peer e scomparso o che e scattato il supervision timeout; l’eccezione termina qualunque chiamata a read(), write() o notified() fosse in corso, e qualsiasi blocco async with connection esce in modo pulito.

Un central che deve riconnettersi in caso di perdita avvolge il lavoro nel proprio loop esterno:

async def keep_streaming():
    while True:
        try:
            await stream_heart_rate()
        except aioble.DeviceDisconnectedError:
            print("disconnected, retrying...")
            await asyncio.sleep(2)

11.10.5.1. Racchiudere una sequenza con timeout()#

Quando diverse operazioni GATT consecutive devono completarsi tutte entro un unico budget – e non ciascuna individualmente con il proprio timeout_ms – usa aioble.DeviceConnection.timeout() per avvolgerle. Il context manager restituito annulla il proprio corpo se il budget si esaurisce (sollevando asyncio.TimeoutError) o se il peer si disconnette (sollevando aioble.DeviceDisconnectedError):

async with await device.connect() as connection:
    try:
        with connection.timeout(2000):                    # 2 s for the whole block
            service = await connection.service(HR_SERVICE)
            char = await service.characteristic(HR_MEASUREMENT)
            await char.subscribe(notify=True)
    except asyncio.TimeoutError:
        print("discovery + subscribe took too long")

Questa e l’alternativa piu pulita all’avvolgere ogni chiamata individualmente in asyncio.wait_for() ed evita successi spuri in cui ogni chiamata rispetta la propria scadenza ma la sequenza nel suo complesso sfora. Passare timeout_ms=None a timeout() disabilita la scadenza e lascia attiva solo la protezione dalla disconnessione.