11.9. Als Peripheriegerät agieren¶
Das häufigste kameraseitige BLE-Muster besteht darin, als Peripheriegerät zu agieren – eine kleine GATT-Datenbank zu veröffentlichen, ihre Existenz zu bewerben, eine Verbindung von einem Telefon oder einem Begleitgerät anzunehmen und Werte an die Gegenstelle zu streamen.
11.9.1. Aufbau der GATT-Datenbank¶
Das Erste, was ein Peripheriegerät beim Start tut – noch bevor das Funkmodul eingeschaltet wird – ist der Aufbau der Datenbank, die es bereitstellen möchte: Es erstellt Objekte für jeden Dienst und jede Charakteristik und registriert anschließend das Ganze:
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)
Jede aioble.Characteristic wird ihrem Dienst einfach dadurch zugeordnet, dass sie mit dem Dienst als erstem Argument konstruiert wird. Die booleschen Schlüsselwortargumente (read, write, write_no_response, notify, indicate) wählen aus, welche GATT-Operationen der Client ausführen darf; die Übergabe von False (der Standardwert) bedeutet, dass das Eigenschaftsbit nicht gesetzt wird.
aioble.register_services() überträgt den zusammengestellten Baum an den GATT-Server. Diese Funktion muss einmal aufgerufen werden, bevor ein aioble.advertise() startet; ein erneuter Aufruf ersetzt die vorherige Datenbank.
11.9.2. Werbung (Advertising)¶
Sobald die Datenbank steht, ist das Advertising ein einziger Coroutine-Aufruf, der auf eine Verbindung wartet:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Die Schlüsselwortargumente werden direkt auf die Felder der Advertising-Nutzlast abgebildet. name ist das Local-Name-Feld; services ist die Liste der Dienst-UUIDs, die das Gerät hostet (ein telefonseitiger Scanner kann danach filtern); appearance ist ein Hinweis aus den standardisierten 16-Bit-Appearance-Werten, mit dem das zentrale Gerät ein sinnvolles Symbol anzeigen kann. Herstellerspezifische Daten werden über manufacturer=(company_id, data_bytes) übergeben.
Eine Handvoll weniger gebräuchlicher Schlüsselwörter deckt den Rest des Advertising-Flag-Raums ab:
connectable=False– reiner Broadcast-Modus (es wird niemals eine Verbindung angenommen). Die richtige Wahl für Beacon-artige Nutzlasten.limited_disc=True– verwendet das Flag limited discoverable anstelle von general discoverable; einige Betriebssysteme behandeln die beiden in ihrer Pairing-Oberfläche unterschiedlich.adv_data/resp_data– rohe Bytes, falls die Anwendung volle Kontrolle über das Layout benötigt.timeout_ms– nach einer festen Zeit aufgeben. Standardmäßig wird unbegrenzt geworben.
Wenn sich ein zentrales Gerät verbindet, gibt aioble.advertise() die resultierende aioble.DeviceConnection zurück. Das Peripheriegerät stoppt zu diesem Zeitpunkt das Advertising.
11.9.3. Einen Client bedienen¶
Die Hauptschleife eines Peripheriegeräts sieht typischerweise so aus:
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 macht das Aufräumen bei der Trennung automatisch. disconnected() ist eine Coroutine, die so lange aussetzt, bis eine der beiden Seiten die Verbindung beendet – eine saubere Methode, das Peripheriegerät bedienend zu halten, bis das zentrale Gerät verschwindet, und dann zurück zum Advertising der nächsten Runde zu springen.
11.9.4. Eine Charakteristik aktualisieren¶
Das Peripheriegerät aktualisiert die lokale GATT-Datenbank mit aioble.Characteristic.write():
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Das ändert den Wert, den ein nächstes read von einem beliebigen Client zurückgeben würde. Für sich genommen schiebt es den neuen Wert nicht aktiv heraus – ein abonnierter Client sieht nichts, bis entweder der Client abfragt oder das Peripheriegerät eine explizite Benachrichtigung sendet.
Die Push-Seite ist ein einzelnes Schlüsselwort im selben Aufruf:
temp_char.write(temp_bytes, send_update=True)
send_update=True benachrichtigt (oder indiziert) jeden Client, der diese Charakteristik abonniert hat. Der meiste sensorartige Code lebt in einer verbindungsbezogenen Task, die in einer Schleife den Sensor liest und den Wert mit send_update=True etwa jede Sekunde schreibt:
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()
Wenn Sie eine Benachrichtigung lieber an einen bestimmten Client richten möchten statt an die gesamte abonnierte Gruppe (etwa eine verbindungsprivate Antwort auf den Befehl dieses Clients), nehmen aioble.Characteristic.notify() und indicate() ein DeviceConnection-Argument und eine optionale Nutzlast entgegen.
11.9.5. Schreibvorgänge empfangen¶
Die andere Richtung – ein Client, der in eine Charakteristik schreibt – wird verfügbar, wenn die Charakteristik mit write=True oder write_no_response=True konstruiert wird. Das Peripheriegerät erwartet den nächsten Schreibvorgang mit 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)
Ohne capture=True gibt written() nur die schreibende Verbindung zurück; der neue Wert liegt im Backing-Puffer der Charakteristik und die Anwendung holt ihn mit read() ab. Wenn ein zweiter Schreibvorgang eintrifft, bevor die Anwendung den ersten gelesen hat, überschreibt der zweite Wert den ersten im Puffer und der ursprüngliche Wert geht verloren – written() weckt die Anwendung weiterhin auf, aber nur einmal pro „es gibt etwas Neues“, nicht einmal pro Schreibvorgang.
Das Schlüsselwort capture=True behebt das. Jeder eingehende Schreibvorgang wird an eine modulweite Warteschlange angehängt, und written() gibt für jeden einzelnen Schreibvorgang ein (connection, data)-Tupel zurück – die Anwendungsschleife sieht jeden genau einmal, in Eingangsreihenfolge. Zwei praktische Konsequenzen:
Die Warteschlange ist begrenzt und wird von jeder capture-fähigen Charakteristik auf dem Gerät gemeinsam genutzt. Kurze Bursts aufeinanderfolgender Schreibvorgänge werden toleriert; anhaltender Überlauf (Schreibvorgänge treffen schneller ein, als die Anwendung sie abarbeitet) verwirft stillschweigend die ältesten in der Warteschlange befindlichen Einträge, und stoßweiser Verkehr auf einer Charakteristik kann ausstehende Einträge einer anderen verdrängen.
Wählen Sie
capture=Truefür befehlsartige Schreibvorgänge, bei denen jeder Wert zählt. Lassen Sie es bei zustandsartigen Charakteristiken weg, bei denen nur der neueste Wert von Interesse ist.
Wenn ein Lesevorgang des Clients von bei Bedarf ausgeführtem Code statt von einem statischen Wert beantwortet werden soll, überschreiben Sie on_read(). Die Methode wird synchron aufgerufen, wenn ein Lesevorgang eintrifft; geben Sie 0 zurück, um den Lesevorgang zu erlauben (der aktuelle Wert von write() wird gesendet), oder einen ATT-Fehlercode ungleich null, um ihn abzulehnen:
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)
Der Callback erfasst den Sensor und aktualisiert den Wert der Charakteristik unmittelbar bevor der GATT-Stack den Lesevorgang bedient, sodass der Client stets frische Daten sieht. Die Ratenbegrenzung verhindert, dass ein Client den Sensor schneller abfragt, als er erfasst werden kann – jeder Lesevorgang innerhalb der einsekündigen Abkühlphase wird als Read Not Permitted-ATT-Fehler zurückgewiesen statt als veralteter Wert.
11.9.5.1. Größere Backing-Puffer – BufferedCharacteristic¶
Der Backing-Puffer für eine reguläre Characteristic ist 20 Byte breit – die praktische Grenze bei der standardmäßigen MTU von 23 Byte. Ein Client, der mehr als das in eine reguläre Charakteristik schreibt, bekommt seinen Wert abgeschnitten. Für größere eingehende Werte oder zum Einreihen aufeinanderfolgender Schreibvorgänge, die die Anwendungsschleife später aufholt, deklarieren Sie die Charakteristik als BufferedCharacteristic und legen die Puffergröße im Voraus fest:
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)
Zwei Stellschrauben unterscheiden sie von einer einfachen Characteristic:
max_lenist die Größe des Backing-Puffers in Bytes. Wählen Sie sie so, dass sie dem größten einzelnen Schreibvorgang entspricht, den der Client voraussichtlich vornimmt (nach der MTU-Aushandlung).append=Truebewirkt, dass aufeinanderfolgende Schreibvorgänge an den Puffer angehängt werden, statt ihn zu überschreiben – nützlich für den Empfang eines Werts, der über mehrere Schreibvorgänge hinweg eintrifft (Firmware-Update-Chunks, Logzeilen). Mitappend=Falseverhält sich der Puffer wie eine normale Charakteristik, nur breiter.
Alle übrigen Konstruktor-Flags (read, write, notify, indicate, capture, initial) werden unverändert an die zugrunde liegende Charakteristik weitergereicht.
11.9.6. Standarddienste und die von der SIG zugewiesenen UUIDs¶
Wenn man bei den UUIDs der zugewiesenen Nummern bleibt (0x180F für Battery Service, 0x181A für Environmental Sensing, 0x180D für Heart Rate und so weiter), kann das generische Bluetooth-Menü eines Telefons oder eine beliebige Drittanbieter-Scanner-App den Zweck des Geräts ohne jeglichen benutzerdefinierten Client-Code erkennen. Das Byte-Layout innerhalb jeder Standardcharakteristik ist ebenfalls durch die Spezifikation festgelegt – Battery Level (0x2A19) ist ein einzelnes Byte 0..100; Temperature (0x2A6E) ist ein Little-Endian-sint16 in Einheiten von 0,01 Grad Celsius. Für Anwendungen, die nicht zu einem Standarddienst passen, erzeugen Sie einmalig eine 128-Bit-UUID und verwenden sie über die Dienste und Charakteristiken des Geräts hinweg.
Ein Peripheriegerät, das nur benutzerdefinierte UUIDs veröffentlicht, ist trotzdem in Ordnung – es braucht lediglich eine benutzerdefinierte Client-App, die diese UUIDs kennt.
Bemerkung
BLE-Werte sind überall Little-Endian – die GATT-Spezifikation, jede Standardcharakteristik, jedes Advertising-Feld. Mehrbyte-Ganzzahlen gehen mit dem niederwertigsten Byte zuerst über die Leitung. Das Präfix < in struct-Formatstrings ist das, was Sie zum Kodieren/Dekodieren wollen ("<h", "<H", "<I", …); die standardmäßige native Byte-Reihenfolge auf einem Little-Endian-MCU zu verwenden, funktioniert zwar zufällig vorerst, aber < auszuschreiben ist die sichere Angewohnheit.
11.9.7. Das Funkmodul dahinter¶
Das Funkmodul ist eingeschaltet, sobald die erste aioble-Coroutine darauf zugreift. Bis ein zentrales Gerät verbunden ist, verbringt das Peripheriegerät seine Zeit damit, zwischen kurzen Advertising-Bursts und Schlaf zu wechseln; nach einer Verbindung folgt es dem ausgehandelten Verbindungsintervall. Das Peripheriegerät zahlt pro Advertisement einen geringen Energiepreis, daher ist die Wahl von interval_us bei aioble.advertise() die unmittelbarste Stellschraube, über die ein Peripheriegerät verfügt, um Erkennungslatenz gegen Akkulaufzeit abzuwägen.