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.