11.10. Als Central agieren

Die andere Seite der Unterhaltung ist das Central – das Gerät, das nach werbenden Peripheriegeräten scannt, eines zum Sprechen auswählt, eine Verbindung öffnet, die entfernte GATT-Datenbank durchläuft und Charakteristiken darauf liest oder abonniert. Eine Kamera, die Messwerte von einem tragbaren Sensor sammelt, einem Beacon zuhört oder mit einem Begleit-Mikrocontroller spricht, ist ein Central.

Das Central-Muster in aioble läuft über vier Phasen: Scannen, Verbinden, Entdecken, Bedienen.

11.10.1. Scannen

aioble.scan() gibt einen async-Kontextmanager zurück, der zugleich als async-Iterator über die entdeckten Geräte dient. Die typische Verwendung ist, zu scannen, bis ein interessantes Gerät auftaucht, und dann aus der Iteration auszubrechen:

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 begrenzt, wie lange der Scan läuft; duration_ms=0 scannt für immer (bis der Kontextmanager verlassen wird). active=True fordert Scan-Antworten an, was die Payload-Größe pro Gerät verdoppelt, allerdings zum Preis einer kleinen zusätzlichen Übertragung von beiden Seiten. Die übrigen Schlüsselwortargumente interval_us / window_us stellen den Funk-Arbeitszyklus des Scanners selbst ein und werden selten von den Standardwerten abgeändert.

Jedes aioble.ScanResult stellt die Geräteadresse, den letzten RSSI, die rohen Werbe- und Scan-Antwort-Bytes sowie Helfer bereit, die die Standardfelder parsen:

  • result.device – ein aioble.Device, bereit, connect() darauf aufzurufen.

  • result.rssi – Indikator für die empfangene Signalstärke in dBm, nützlich für „das nächstgelegene auswählen“-Logik.

  • result.name() – die Local-Name-Zeichenkette oder None, wenn nicht beworben.

  • result.services() – ein Generator von bluetooth.UUID für jeden Dienst, den das Gerät bewirbt.

  • result.manufacturer() – ein Generator von (company_id, data)-Tupeln für die herstellerspezifischen Felder.

  • result.connectable – ob die jüngste Werbung eine verbindungsfähige war.

Dasselbe ScanResult wird erneut geliefert, sobald neue Werbedaten für dasselbe Gerät eintreffen, sodass ein passiver Zuhörer, der nur Geräte unbegrenzt verfolgen möchte, den async-Iterator endlos laufen lassen und bei jedem Ereignis reagieren kann.

11.10.2. Verbinden

Sobald ein Zielgerät identifiziert ist, ist das Öffnen einer Verbindung ein einziges await:

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

aioble.Device.connect() nimmt timeout_ms (wie lange auf das Zustandekommen der Verbindung gewartet wird; Standard 10 s) sowie min_conn_interval_us / max_conn_interval_us (der angeforderte Bereich des Verbindungsintervalls aus Verbindungen).

11.10.2.1. Wiederverbinden mit einem bekannten Peer ohne Scannen

Sobald eine Bindung mit einem Peer besteht, ist die Adresse bereits bekannt, und eine weitere Runde aus Scannen und Auswählen ist verschwendete Funkzeit. Konstruiere ein aioble.Device direkt mit der gespeicherten Adresse und springe direkt zu 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

Das erste Argument ist eines von aioble.ADDR_PUBLIC (die werkseitige Adresse eines Controllers) oder aioble.ADDR_RANDOM (eine erzeugte statische oder auflösbare private Adresse); das zweite ist entweder ein Sechs-Byte-bytes-Wert oder eine durch Doppelpunkte getrennte Hex-Zeichenkette. Die Attribute addr_type und addr eines beliebigen Device (z. B. eines zuvor aus einem ScanResult gewonnenen) können persistiert und hier wieder eingespeist werden.

Das zurückgegebene aioble.DeviceConnection ist das, woran der Rest der Arbeit des Centrals hängt. async with stellt sicher, dass die Verbindung geschlossen wird, wenn der Block verlassen wird – bei Erfolg, bei Abbruch oder bei jeder Ausnahme, einschließlich aioble.DeviceDisconnectedError, weil der Peer verschwindet.

Wenn das Central einen größeren Charakteristik-Wert benötigt, als die standardmäßige 23-Byte-MTU erlaubt, ist dies der Ort, um ihn auszuhandeln:

await connection.exchange_mtu(512)

(exchange_mtu() gibt die tatsächlich ausgehandelte MTU zurück, die das Minimum aus dem angeforderten Wert und dem ist, was der Peer unterstützt.)

11.10.3. Entdeckung

Die Entdeckung durchläuft die entfernte GATT-Datenbank, um die Dienste und Charakteristiken anhand ihrer UUIDs zu finden. Es gibt zwei Varianten: gezielt (du kennst die UUID und willst eine bestimmte Sache) und vollständig (du willst alles).

Gezielt – der häufige Fall:

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() und aioble.ClientService.characteristic() nehmen jeweils eine bluetooth.UUID und geben das passende Objekt zurück (oder None). Beide haben ein timeout_ms-Schlüsselwort pro Entdeckung, das standardmäßig 2 s beträgt.

Vollständig:

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

Das ist es, was generische Bluetooth-Explorer-Apps tun – nützlich für die Entwicklung, weniger für Produktionscode, der weiß, welche UUIDs er erwartet.

11.10.3.1. Untersuchen, was eine Charakteristik unterstützt

Die Entdeckung gibt die GATT-Eigenschaftsbitmaske zurück, die der Peer für jede Charakteristik beworben hat, als properties. Die Bits sind die GATT-definierten – read (0x02), write-without-response (0x04), write (0x08), notify (0x10), indicate (0x20) und Verwandte. Das Untersuchen der Bitmaske vor dem Ausführen einer Operation lässt einen generischen Client sich an Charakteristiken anpassen, deren Fähigkeiten er nicht im Voraus kennt:

_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

Produktionscode, der das GATT-Profil des Peers bereits kennt, benötigt dies in der Regel nicht – die UUIDs wurden von Anfang an dokumentiert. Generische / explorative Clients (eine Einstellungsseite, die ein unbekanntes Gerät durchläuft, ein Plugin-Host) stützen sich darauf.

11.10.4. Bedienen

Sobald das Central eine ClientCharacteristic hält, ist jede GATT-Operation ein einziger Coroutinen-Aufruf:

  • Read. Setze eine GATT-Leseoperation ab und erhalte den Wert zurück:

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

    Lange Lesevorgänge (Werte größer als die MTU) werden transparent gehandhabt.

  • Write. Sende einen neuen Wert an den Server:

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

    response=True wartet auf eine Write-Antwort und löst aioble.GattError aus, wenn der Server das Schreiben ablehnt. response=False ist write-without-response: feuern und vergessen. response=None (der Standard) wählt automatisch anhand dessen aus, was der Peer beworben hat.

  • Subscribe. Aktiviere Benachrichtigungen oder Indikationen, indem du in das CCCD der Charakteristik schreibst:

    await char.subscribe(notify=True)
    

    Nachdem dies zurückgekehrt ist, kann das Central auf eingehende Pushes warten.

  • Notified / indicated. Warte auf den nächsten Push vom Server:

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

    timeout_ms=None (der Standard) wartet für immer; übergib eine Ganzzahl in Millisekunden, um nach einer Weile aufzugeben.

Die vier zusammenzusetzen ergibt das kanonische „verbinden, abonnieren, streamen“-Central-Programm:

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

Das Ganze umfasst etwa ein Dutzend Zeilen und deckt den Ablauf von „kein Bluetooth läuft“ bis zu „Live-Daten-Streaming“ ab. Der Scan-Iterator entspricht dem Broadcaster/Observer-Muster, connect öffnet die GAP-Verbindung, service / characteristic durchläuft den GATT-Baum, subscribe schreibt das CCCD, und notified wartet auf Pushes.

11.10.5. Verbindungsabbrüche und Wiederverbindung

Alles, was mit der Funkverbindung geschieht, taucht in der Coroutine auf, die darauf gewartet hat. aioble.DeviceDisconnectedError ist das Signal, dass der Peer verschwunden ist oder der Supervision-Timeout ausgelöst hat; die Ausnahme beendet, welcher read()-, write()- oder notified()-Aufruf auch immer gerade lief, und jeder async with connection-Block wird sauber verlassen.

Ein Central, das bei Verlust wiederverbinden soll, umschließt die Arbeit mit seiner eigenen äußeren Schleife:

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. Eine Sequenz mit timeout() einklammern

Wenn mehrere aufeinanderfolgende GATT-Operationen alle innerhalb eines einzigen Budgets abgeschlossen werden sollen – nicht jede einzeln mit ihrem eigenen timeout_ms – verwende aioble.DeviceConnection.timeout(), um sie zu umschließen. Der zurückgegebene Kontextmanager bricht seinen Rumpf ab, wenn das Budget abläuft (und löst asyncio.TimeoutError aus) oder wenn der Peer die Verbindung trennt (und aioble.DeviceDisconnectedError auslöst):

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

Dies ist die sauberere Alternative dazu, jeden Aufruf einzeln in asyncio.wait_for() zu umschließen, und vermeidet trügerische Erfolge, bei denen jeder Aufruf seine eigene Frist einhält, die Sequenz als Ganzes jedoch überzieht. Das Übergeben von timeout_ms=None an timeout() deaktiviert die Frist und lässt nur den Disconnect-Schutz aktiv.