11.9. Optreden als randapparaat¶
Het meest voorkomende BLE-patroon aan de camerakant is om als randapparaat op te treden – een kleine GATT-database publiceren, het bestaan ervan adverteren, een verbinding van een telefoon of een begeleidend apparaat accepteren en waarden streamen naar wie er aan de andere kant zit.
11.9.1. De GATT-database opbouwen¶
Het eerste wat een randapparaat bij het opstarten doet – nog voordat het de radio inschakelt – is de database opbouwen die het wil blootstellen, objecten aanmaken voor elke service en elk kenmerk, en vervolgens het geheel registreren:
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)
Elk aioble.Characteristic wordt aan zijn service gekoppeld door het simpelweg te construeren met de service als eerste argument. De booleaanse sleutelwoordargumenten (read, write, write_no_response, notify, indicate) selecteren welke GATT-bewerkingen de client mag uitvoeren; het doorgeven van False (de standaard) betekent dat de eigenschapsbit niet wordt ingesteld.
aioble.register_services() legt de samengestelde boom vast op de GATT-server. Het moet eenmaal worden aangeroepen, voordat een aioble.advertise() start; het opnieuw aanroepen vervangt de vorige database.
11.9.2. Adverteren¶
Zodra de database op zijn plaats staat, is adverteren een enkele coroutine-aanroep die op een verbinding wacht:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
De sleutelwoordargumenten worden rechtstreeks toegewezen aan de velden van de advertentiepayload. name is het local-name-veld; services is de lijst met service-UUID’s die het apparaat host (een scanner aan de telefoonkant kan hierop filteren); appearance is een hint uit de standaard 16-bits appearance-waarden waarmee de central een zinvol pictogram kan tonen. Fabrikantspecifieke data wordt meegegeven via manufacturer=(company_id, data_bytes).
Een handvol minder gangbare sleutelwoorden dekt de rest van de advertentie-vlagruimte:
connectable=False– alleen-broadcastmodus (er wordt nooit een verbinding geaccepteerd). De juiste keuze voor beacon-achtige payloads.limited_disc=True– gebruik de vlag limited discoverable in plaats van general discoverable; sommige besturingssystemen behandelen de twee verschillend in hun koppelings-UI.adv_data/resp_data– ruwe bytes als de toepassing volledige controle nodig heeft over de indeling.timeout_ms– geef op na een vaste tijd. De standaard is om eeuwig te adverteren.
Wanneer een central verbinding maakt, retourneert aioble.advertise() de resulterende aioble.DeviceConnection. Het randapparaat stopt op dit punt met adverteren.
11.9.3. Eén client bedienen¶
De hoofdlus van een randapparaat ziet er doorgaans als volgt uit:
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 maakt het opschonen van de verbinding bij verbreking automatisch. disconnected() is een coroutine die opschort totdat een van beide zijden de verbinding beëindigt – een nette manier om het randapparaat te laten bedienen totdat de central weggaat, en dan terug te lussen naar het adverteren van de volgende ronde.
11.9.4. Een kenmerk bijwerken¶
Het randapparaat werkt de lokale GATT-database bij met aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Dat wijzigt de waarde die de volgende read van een willekeurige client zou retourneren. Op zichzelf duwt het de nieuwe waarde niet door – een geabonneerde client ziet niets totdat de client peilt of het randapparaat een expliciete notificatie verstuurt.
De push-zijde is een enkel sleutelwoord op dezelfde aanroep:
temp_char.write(temp_bytes, send_update=True)
send_update=True notificeert (of indiceert) elke client die zich op dit kenmerk heeft geabonneerd. De meeste sensorachtige code leeft in een taak per verbinding die in een lus de sensor uitleest en de waarde wegschrijft met send_update=True, ongeveer elke seconde:
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()
Als je een notificatie liever naar één specifieke client wilt richten in plaats van naar de hele geabonneerde set (bijvoorbeeld een verbindingsprivé-antwoord op het commando van die client), nemen aioble.Characteristic.notify() en indicate() een DeviceConnection-argument en een optionele payload.
11.9.5. Schrijfacties ontvangen¶
De andere richting – een client die naar een kenmerk schrijft – wordt beschikbaar wanneer het kenmerk wordt geconstrueerd met write=True of write_no_response=True. Het randapparaat wacht op de volgende schrijfactie met 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)
Zonder capture=True retourneert written() alleen de schrijvende verbinding; de nieuwe waarde leeft in de onderliggende buffer van het kenmerk en de toepassing haalt deze op met read(). Als een tweede schrijfactie binnenkomt voordat de toepassing de eerste heeft gelezen, overschrijft de tweede waarde de eerste in de buffer en gaat de oorspronkelijke waarde verloren – written() wekt de toepassing nog steeds, maar slechts eenmaal per “er is iets nieuws”, niet eenmaal per schrijfactie.
Het sleutelwoord capture=True lost dat op. Elke binnenkomende schrijfactie wordt toegevoegd aan een modulebrede wachtrij, en written() retourneert een (connection, data)-tuple voor elke afzonderlijke schrijfactie – de toepassingslus ziet elke schrijfactie precies eenmaal, in volgorde van aankomst. Twee praktische gevolgen:
De wachtrij is begrensd en wordt gedeeld over elk capture-ingeschakeld kenmerk op het apparaat. Korte uitbarstingen van opeenvolgende schrijfacties worden getolereerd; aanhoudende overloop (schrijfacties die sneller binnenkomen dan de toepassing ze verwerkt) laat stilletjes de oudste in de wachtrij geplaatste vermeldingen vallen, en uitbarstingen op één kenmerk kunnen wachtende vermeldingen van een ander kenmerk verdringen.
Kies
capture=Truevoor commando-achtige schrijfacties waarbij elke waarde telt. Laat het uit voor toestand-achtige kenmerken waarbij alleen de meest recente waarde van belang is.
Als een leesactie van de client moet worden beantwoord door code die op aanvraag draait in plaats van door een statische waarde, overschrijf dan on_read(). De methode wordt synchroon aangeroepen wanneer er een leesactie binnenkomt; retourneer 0 om de leesactie toe te staan (de huidige waarde van write() wordt verzonden), of een ATT-foutcode die niet nul is om deze te weigeren:
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)
De callback bemonstert de sensor en werkt de waarde van het kenmerk bij vlak voordat de GATT-stack de leesactie bedient, zodat de client altijd verse data ziet. De snelheidslimiet voorkomt dat een client de sensor sneller bestookt dan deze kan worden bemonsterd – elke leesactie binnen de afkoelperiode van één seconde wordt teruggekaatst als een Read Not Permitted ATT-fout in plaats van een verouderde waarde.
11.9.5.1. Grotere onderliggende buffers – BufferedCharacteristic¶
De onderliggende buffer voor een gewone Characteristic is 20 bytes breed – de praktische limiet bij de standaard MTU van 23 bytes. Een client die meer dan dat in een gewoon kenmerk schrijft, krijgt zijn waarde afgekapt. Voor grotere binnenkomende waarden of voor het in de wachtrij plaatsen van opeenvolgende schrijfacties die de toepassingslus later zal inhalen, declareer het kenmerk als BufferedCharacteristic en kies vooraf de buffergrootte:
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)
Twee knoppen onderscheiden het van een gewone Characteristic:
max_lenis de grootte van de onderliggende buffer in bytes. Kies deze zodat hij overeenkomt met de grootste enkele schrijfactie die de client naar verwachting zal doen (na MTU-onderhandeling).append=Truezorgt ervoor dat opeenvolgende schrijfacties in de buffer toevoegen in plaats van overschrijven – handig voor het ontvangen van een waarde die over meerdere schrijfacties binnenkomt (firmware-update-brokken, logregels). Metappend=Falsegedraagt de buffer zich als een normaal kenmerk, alleen breder.
Alle andere constructorvlaggen (read, write, notify, indicate, capture, initial) worden ongewijzigd doorgegeven aan het onderliggende kenmerk.
11.9.6. Standaardservices en de door de SIG toegewezen UUID’s¶
Vasthouden aan de UUID’s met toegewezen nummers (0x180F voor Battery Service, 0x181A voor Environmental Sensing, 0x180D voor Heart Rate, enzovoort) betekent dat het generieke Bluetooth-menu van een telefoon of een willekeurige scanner-app van derden het doel van het apparaat kan identificeren zonder enige aangepaste clientcode. De byte-indeling binnen elk standaardkenmerk ligt ook vast in de specificatie – Battery Level (0x2A19) is een enkele byte 0..100; Temperature (0x2A6E) is little-endian sint16 in eenheden van 0,01 graad-C. Voor toepassingen die niet in een standaardservice passen, genereer eenmalig een 128-bits UUID en gebruik deze in de services en kenmerken van het apparaat.
Een randapparaat dat alleen aangepaste UUID’s publiceert is nog steeds prima – het heeft alleen een aangepaste client-app nodig die op de hoogte is van die UUID’s.
Notitie
BLE-waarden zijn overal little-endian – de GATT-specificatie, elk standaardkenmerk, elk advertentieveld. Multi-byte gehele getallen gaan met de laagste byte eerst over de lijn. Het <-voorvoegsel in de struct-opmaaktekenreeksen is wat je nodig hebt voor coderen/decoderen ("<h", "<H", "<I", …); het gebruiken van de standaard native byte-volgorde op een little-endian MCU werkt voorlopig toevallig, maar het uitschrijven van < is de veilige gewoonte.
11.9.7. De radio achter dit alles¶
De radio staat aan op het moment dat de eerste aioble-coroutine deze aanraakt. Totdat een central is verbonden, brengt het randapparaat zijn tijd door met schakelen tussen korte advertentie-uitbarstingen en slaap; na een verbinding volgt het het onderhandelde verbindingsinterval. Het randapparaat betaalt een kleine energiekost per advertentie, dus de keuze van interval_us op aioble.advertise() is de meest directe knop die een randapparaat heeft om ontdekkingslatentie af te wegen tegen batterijduur.