11.9. Agire come periferica¶
Il pattern BLE lato camera piu comune consiste nell’agire come periferica: pubblicare un piccolo database GATT, segnalare la propria esistenza, accettare una connessione da un telefono o da un dispositivo companion e trasmettere valori a chiunque si trovi dall’altra parte.
11.9.1. Costruire il database GATT¶
La prima cosa che una periferica fa all’avvio – ancor prima di accendere la radio – e costruire il database che intende esporre, creare gli oggetti per ciascun servizio e caratteristica e poi registrare il tutto:
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)
Ogni aioble.Characteristic viene collegata al proprio servizio semplicemente costruendola con il servizio come primo argomento. Gli argomenti keyword booleani (read, write, write_no_response, notify, indicate) selezionano quali operazioni GATT il client potra eseguire; passare False (il valore predefinito) significa che il bit della proprieta non viene impostato.
aioble.register_services() registra l’albero assemblato sul server GATT. Deve essere chiamata una sola volta, prima che inizi qualsiasi aioble.advertise(); chiamarla di nuovo sostituisce il database precedente.
11.9.2. Advertising¶
Una volta predisposto il database, l’advertising consiste in una singola chiamata a coroutine che attende una connessione:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Gli argomenti keyword si mappano direttamente sui campi del payload di advertising. name e il campo local-name; services e l’elenco degli UUID dei servizi ospitati dal dispositivo (uno scanner lato telefono puo filtrare su questi); appearance e un suggerimento tratto dai valori standard di appearance a 16 bit che consente al central di mostrare un’icona sensata. I dati specifici del produttore vengono inseriti tramite manufacturer=(company_id, data_bytes).
Un gruppo ristretto di keyword meno comuni copre il resto dello spazio dei flag di advertising:
connectable=False– modalita solo broadcast (nessuna connessione viene mai accettata). La scelta giusta per payload in stile beacon.limited_disc=True– usa il flag limited discoverable anziche general discoverable; alcuni sistemi operativi trattano i due in modo diverso nella loro interfaccia di pairing.adv_data/resp_data– byte grezzi se l’applicazione necessita del controllo completo sul layout.timeout_ms– rinuncia dopo un tempo prefissato. Il comportamento predefinito e fare advertising all’infinito.
Quando un central si connette, aioble.advertise() restituisce la aioble.DeviceConnection risultante. A questo punto la periferica smette di fare advertising.
11.9.3. Servire un client¶
Il loop principale di una periferica ha tipicamente questo aspetto:
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 rende automatica la pulizia alla disconnessione. disconnected() e una coroutine che si sospende finche una delle due parti non termina la connessione – un modo pulito per mantenere la periferica in servizio finche il central non se ne va, per poi tornare a fare advertising al giro successivo.
11.9.4. Aggiornare una caratteristica¶
La periferica aggiorna il database GATT locale con aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Cio modifica il valore che la successiva read da parte di qualsiasi client restituirebbe. Di per se, non invia in push il nuovo valore – un client iscritto non vedra nulla finche il client non esegue un polling oppure la periferica non invia una notifica esplicita.
Il lato push e una singola keyword nella stessa chiamata:
temp_char.write(temp_bytes, send_update=True)
send_update=True notifica (o indica) ogni client che si e iscritto a questa caratteristica. La maggior parte del codice in stile sensore vive in un task per-connessione che cicla leggendo il sensore e scrivendo il valore con send_update=True ogni secondo circa:
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()
Se preferisci indirizzare una notifica a uno specifico client anziche all’intero insieme degli iscritti (ad esempio una risposta privata della connessione al comando di quel client), aioble.Characteristic.notify() e indicate() accettano un argomento DeviceConnection e un payload opzionale.
11.9.5. Ricevere scritture¶
La direzione opposta – un client che scrive su una caratteristica – diventa disponibile quando la caratteristica viene costruita con write=True o write_no_response=True. La periferica attende la prossima scrittura con 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)
Senza capture=True, written() restituisce solo la connessione che ha effettuato la scrittura; il nuovo valore risiede nel buffer di backing della caratteristica e l’applicazione lo recupera con read(). Se una seconda scrittura arriva prima che l’applicazione abbia letto la prima, il secondo valore sovrascrive il primo nel buffer e il valore originale viene perso – written() risveglia comunque l’applicazione, ma solo una volta per «c’e qualcosa di nuovo», non una volta per scrittura.
La keyword capture=True risolve questo problema. Ogni scrittura in arrivo viene accodata in una coda a livello di modulo, e written() restituisce una tupla (connection, data) per ogni singola scrittura – il loop dell’applicazione vede ciascuna esattamente una volta, nell’ordine di arrivo. Due conseguenze pratiche:
La coda ha dimensione limitata ed e condivisa tra tutte le caratteristiche con capture abilitato sul dispositivo. Brevi raffiche di scritture consecutive sono tollerate; un sovraccarico prolungato (scritture che arrivano piu velocemente di quanto l’applicazione le svuoti) scarta silenziosamente le voci accodate piu vecchie, e il traffico a raffiche su una caratteristica puo espellere voci in attesa di un’altra.
Scegli
capture=Trueper scritture in stile comando in cui ogni valore conta. Lascialo disattivato per caratteristiche in stile stato in cui l’unico valore di interesse e l’ultimo.
Se una lettura da parte del client deve essere soddisfatta da codice eseguito su richiesta anziche da un valore statico, ridefinisci on_read(). Il metodo viene chiamato in modo sincrono quando arriva una lettura; restituisci 0 per consentire la lettura (verra inviato il valore corrente da write()), oppure un codice di errore ATT diverso da zero per rifiutarla:
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)
La callback campiona il sensore e aggiorna il valore della caratteristica appena prima che lo stack GATT serva la lettura, cosi il client vede sempre dati freschi. Il limite di frequenza impedisce a un client di sollecitare il sensore piu velocemente di quanto possa essere campionato – qualsiasi lettura entro il cooldown di un secondo viene respinta con un errore ATT Read Not Permitted anziche con un valore obsoleto.
11.9.5.1. Buffer di backing piu grandi – BufferedCharacteristic¶
Il buffer di backing per una Characteristic normale e largo 20 byte – il limite pratico con l’MTU predefinito di 23 byte. Un client che scrive piu di tanto in una caratteristica normale vede il proprio valore troncato. Per valori in arrivo piu grandi o per accodare scritture consecutive che il loop dell’applicazione recuperera in seguito, dichiara la caratteristica come BufferedCharacteristic e scegli a priori la dimensione del buffer:
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)
Due manopole la distinguono da una semplice Characteristic:
max_lene la dimensione del buffer di backing in byte. Scegline il valore in modo che corrisponda alla singola scrittura piu grande che il client si prevede effettui (dopo la negoziazione dell’MTU).append=Truefa si che le scritture sequenziali vengano accodate nel buffer anziche sovrascriverlo – utile per ricevere un valore che arriva attraverso piu scritture (chunk di aggiornamento firmware, righe di log). Conappend=Falseil buffer si comporta come una caratteristica normale, solo piu ampio.
Tutti gli altri flag del costruttore (read, write, notify, indicate, capture, initial) vengono inoltrati invariati alla caratteristica sottostante.
11.9.6. Servizi standard e gli UUID assegnati dal SIG¶
Attenersi agli UUID degli assigned-numbers (0x180F per il Battery Service, 0x181A per l’Environmental Sensing, 0x180D per l’Heart Rate, e cosi via) significa che il menu Bluetooth generico di un telefono o qualsiasi app scanner di terze parti puo identificare lo scopo del dispositivo senza alcun codice client personalizzato. Anche il layout in byte all’interno di ciascuna caratteristica standard e fissato dalla specifica – il Battery Level (0x2A19) e un singolo byte 0..100; la Temperature (0x2A6E) e un sint16 little-endian in unita di 0,01 gradi C. Per applicazioni che non rientrano in un servizio standard, genera una volta un UUID a 128 bit e usalo in tutti i servizi e le caratteristiche del dispositivo.
Una periferica che pubblica solo UUID personalizzati va comunque bene – ha semplicemente bisogno di un’app client personalizzata che conosca quegli UUID.
Nota
I valori BLE sono little-endian ovunque – la specifica GATT, ogni caratteristica standard, ogni campo di advertising. Gli interi multi-byte vanno sul filo con il byte meno significativo per primo. Il prefisso < nelle stringhe di formato di struct e cio che serve per la codifica/decodifica ("<h", "<H", "<I", …); usare l’ordine dei byte nativo predefinito su un MCU little-endian funziona per ora, ma esplicitare < e l’abitudine sicura.
11.9.7. La radio dietro tutto¶
La radio e accesa nel momento in cui la prima coroutine di aioble la tocca. Finche un central non e connesso, la periferica passa il tempo alternando brevi raffiche di advertising e sonno; dopo una connessione segue l’intervallo di connessione negoziato. La periferica paga un piccolo costo energetico per ogni advertisement, quindi la scelta di interval_us in aioble.advertise() e la manopola piu diretta di cui dispone una periferica per bilanciare la latenza di scoperta rispetto alla durata della batteria.