11.10. Działanie jako central¶
Drugą stroną rozmowy jest central – urządzenie, które skanuje w poszukiwaniu rozgłaszających urządzeń peryferyjnych, wybiera jedno do rozmowy, otwiera połączenie, przechodzi przez zdalną bazę danych GATT oraz odczytuje lub subskrybuje znajdujące się w niej charakterystyki. Kamera zbierająca odczyty z noszonego sensora, nasłuchująca beaconu lub rozmawiająca z towarzyszącym mikrokontrolerem jest urządzeniem central.
Wzorzec central w aioble przebiega przez cztery etapy: skanowanie, łączenie, odkrywanie, operowanie.
11.10.1. Skanowanie¶
aioble.scan() zwraca asynchroniczny menedżer kontekstu, który pełni jednocześnie rolę asynchronicznego iteratora po wykrytych urządzeniach. Typowym zastosowaniem jest skanowanie do momentu pojawienia się interesującego urządzenia, a następnie wyjście z iteracji:
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 ogranicza czas trwania skanowania; duration_ms=0 skanuje bez końca (do momentu wyjścia z menedżera kontekstu). active=True żąda odpowiedzi skanowania, co podwaja rozmiar ładunku na urządzenie kosztem niewielkiej dodatkowej transmisji z obu stron. Pozostałe argumenty nazwane interval_us / window_us dostrajają cykl pracy radia samego skanera i rzadko są zmieniane względem wartości domyślnych.
Każdy aioble.ScanResult udostępnia adres urządzenia, ostatnie RSSI, surowe bajty rozgłaszania i odpowiedzi skanowania oraz pomocniki parsujące standardowe pola:
result.device–aioble.Devicegotowe do wywołania na nimconnect().result.rssi– wskaźnik siły odbieranego sygnału w dBm, przydatny w logice „wybierz najbliższe”.result.name()– ciąg z nazwą lokalną lubNone, jeśli nie jest rozgłaszany.result.services()– generator obiektówbluetooth.UUIDdla każdej usługi, którą urządzenie rozgłasza.result.manufacturer()– generator krotek(company_id, data)dla pól specyficznych dla producenta.result.connectable– czy najnowsze rozgłoszenie było rozgłoszeniem dopuszczającym połączenie.
Ten sam ScanResult jest ponownie zwracany w miarę napływania nowych danych rozgłoszeniowych dla tego samego urządzenia, dzięki czemu pasywny nasłuchiwacz, który chce po prostu śledzić urządzenia bez końca, może uruchomić asynchroniczny iterator na zawsze i reagować na każde zdarzenie.
11.10.2. Łączenie¶
Po zidentyfikowaniu docelowego urządzenia otwarcie połączenia to jedno await
async def talk_to(device):
connection = await device.connect() # 10 s timeout
async with connection:
# ... do GATT work ...
pass
aioble.Device.connect() przyjmuje timeout_ms (jak długo czekać na nawiązanie połączenia; domyślnie 10 s) oraz min_conn_interval_us / max_conn_interval_us (żądany zakres interwału połączenia z Połączenia).
11.10.2.1. Ponowne łączenie ze znanym partnerem bez skanowania¶
Gdy istnieje już powiązanie z partnerem, adres jest już znany, a kolejna runda skanowania i wybierania to zmarnowany czas radia. Skonstruuj aioble.Device bezpośrednio z zapisanym adresem i przejdź od razu do 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
Pierwszy argument to jeden z aioble.ADDR_PUBLIC (fabryczny adres kontrolera) lub aioble.ADDR_RANDOM (wygenerowany statyczny albo rozwiązywalny adres prywatny); drugi to wartość bytes o długości sześciu bajtów lub ciąg szesnastkowy rozdzielony dwukropkami. Atrybuty addr_type i addr dowolnego Device (np. uzyskanego wcześniej z ScanResult) można utrwalić i podać tutaj z powrotem.
Zwracany aioble.DeviceConnection to obiekt, na którym opiera się reszta pracy urządzenia central. async with gwarantuje zamknięcie połączenia przy wyjściu z bloku – po sukcesie, po anulowaniu lub przy dowolnym wyjątku, w tym aioble.DeviceDisconnectedError z powodu zniknięcia partnera.
Jeśli central potrzebuje większej wartości charakterystyki, niż pozwala domyślny 23-bajtowy MTU, to jest miejsce, aby ją wynegocjować:
await connection.exchange_mtu(512)
(exchange_mtu() zwraca faktycznie wynegocjowany MTU, czyli minimum z żądanej wartości i tego, co obsługuje partner.)
11.10.3. Odkrywanie¶
Odkrywanie przechodzi przez zdalną bazę danych GATT, aby znaleźć usługi i charakterystyki po ich UUID. Istnieją dwa warianty: ukierunkowany (znasz UUID i chcesz jednej konkretnej rzeczy) oraz wyczerpujący (chcesz wszystkiego).
Ukierunkowany – najczęstszy przypadek:
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() i aioble.ClientService.characteristic() przyjmują bluetooth.UUID i zwracają pasujący obiekt (lub None). Oba mają argument nazwany timeout_ms na pojedyncze odkrywanie, którego wartość domyślna wynosi 2 s.
Wyczerpujący:
async for service in connection.services():
print("service:", service.uuid)
async for char in service.characteristics():
print(" characteristic:", char.uuid, "properties:", hex(char.properties))
Tak właśnie działają ogólne aplikacje typu Bluetooth-explorer – przydatne podczas rozwoju, mniej w kodzie produkcyjnym, który wie, jakich UUID oczekuje.
11.10.3.1. Sprawdzanie, co obsługuje charakterystyka¶
Odkrywanie zwraca dla każdej charakterystyki maskę bitową właściwości GATT rozgłoszoną przez partnera jako properties. Bity to te zdefiniowane przez GATT – odczyt (0x02), zapis bez odpowiedzi (0x04), zapis (0x08), powiadomienie (0x10), wskazanie (0x20) i pokrewne. Sprawdzenie maski bitowej przed wydaniem operacji pozwala ogólnemu klientowi dostosować się do charakterystyk, których możliwości nie zna z góry:
_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
Kod produkcyjny, który zna już profil GATT partnera, zwykle tego nie potrzebuje – UUID zostały udokumentowane z góry. Opierają się na tym klienci ogólni / eksploracyjni (strona ustawień przechodząca przez nieznane urządzenie, host wtyczek).
11.10.4. Operowanie¶
Gdy central dysponuje ClientCharacteristic, każda operacja GATT to jedno wywołanie korutyny:
Odczyt. Wydaj odczyt GATT i odbierz wartość z powrotem:
value = await char.read() print("value:", value)
Długie odczyty (wartości większe niż MTU) są obsługiwane w sposób przezroczysty.
Zapis. Wyślij nową wartość do serwera:
await char.write(b"\\x01")response=Trueczeka na odpowiedź zapisu i zgłaszaaioble.GattError, jeśli serwer odrzuci zapis.response=Falseto zapis bez odpowiedzi: wyślij i zapomnij.response=None(wartość domyślna) automatycznie dobiera wariant na podstawie tego, co rozgłosił partner.Subskrypcja. Włącz powiadomienia lub wskazania, zapisując do CCCD charakterystyki:
await char.subscribe(notify=True)Po jej powrocie central może czekać na przychodzące powiadomienia.
Powiadomienie / wskazanie. Czekaj na następne powiadomienie od serwera:
while True: data = await char.notified() print("push:", data)
timeout_ms=None(wartość domyślna) czeka bez końca; przekaż liczbę całkowitą w milisekundach, aby zrezygnować po pewnym czasie.
Połączenie tych czterech elementów daje kanoniczny program central typu „połącz, subskrybuj, strumieniuj”:
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())
Całość liczy około tuzina linii i obejmuje przepływ od „żaden Bluetooth nie działa” do „strumieniowania danych na żywo”. Iterator skanowania pasuje do wzorca broadcaster/observer, connect otwiera połączenie GAP, service / characteristic przechodzi przez drzewo GATT, subscribe zapisuje CCCD, a notified czeka na powiadomienia.
11.10.5. Rozłączenia i ponowne łączenie¶
Wszystko, co dzieje się z łączem radiowym, ujawnia się w korutynie, która na nie oczekiwała. aioble.DeviceDisconnectedError to sygnał, że partner zniknął lub że wyzwolił się limit czasu nadzoru; wyjątek przerywa dowolne będące w toku wywołanie read(), write() lub notified(), a każdy blok async with connection kończy się czysto.
Central, który ma ponownie łączyć się po utracie, opakowuje pracę we własną pętlę zewnętrzną:
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. Obejmowanie sekwencji za pomocą timeout()¶
Gdy kilka następujących po sobie operacji GATT ma w całości zmieścić się w jednym budżecie – a nie każda osobno na własnym timeout_ms – użyj aioble.DeviceConnection.timeout(), aby je opakować. Zwracany menedżer kontekstu anuluje swoje ciało, jeśli budżet upłynie (zgłaszając asyncio.TimeoutError) lub jeśli partner się rozłączy (zgłaszając 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")
To czystsza alternatywa dla opakowywania każdego wywołania z osobna w asyncio.wait_for() i unika fałszywych sukcesów, w których każde wywołanie mieści się we własnym terminie, ale sekwencja jako całość go przekracza. Przekazanie timeout_ms=None do timeout() wyłącza termin i pozostawia aktywną jedynie ochronę przed rozłączeniem.