11.9. Działanie jako urządzenie peryferyjne¶
Najczęstszym wzorcem BLE po stronie kamery jest działanie jako urządzenie peryferyjne – publikowanie niewielkiej bazy danych GATT, rozgłaszanie swojego istnienia, przyjmowanie połączenia od telefonu lub urządzenia towarzyszącego oraz przesyłanie strumienia wartości do tego, kto znajduje się po drugiej stronie.
11.9.1. Budowanie bazy danych GATT¶
Pierwszą rzeczą, jaką urządzenie peryferyjne robi przy uruchomieniu – jeszcze przed włączeniem radia – jest zbudowanie bazy danych, którą zamierza udostępnić, utworzenie obiektów dla każdej usługi i charakterystyki, a następnie zarejestrowanie całości:
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)
Każda aioble.Characteristic jest dołączana do swojej usługi po prostu przez skonstruowanie jej z usługą jako pierwszym argumentem. Logiczne argumenty słowne (read, write, write_no_response, notify, indicate) wybierają, które operacje GATT klient będzie mógł wykonywać; przekazanie False (wartość domyślna) oznacza, że bit właściwości nie jest ustawiony.
aioble.register_services() zatwierdza zmontowane drzewo na serwerze GATT. Musi zostać wywołane jednokrotnie, zanim rozpocznie się jakiekolwiek aioble.advertise(); ponowne wywołanie zastępuje poprzednią bazę danych.
11.9.2. Rozgłaszanie¶
Gdy baza danych jest już gotowa, rozgłaszanie to jedno wywołanie korutyny, które czeka na połączenie:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Argumenty słowne mapują się bezpośrednio na pola ładunku rozgłaszania. name to pole nazwy lokalnej; services to lista identyfikatorów UUID usług, które hostuje urządzenie (skaner po stronie telefonu może je filtrować); appearance to wskazówka pochodząca ze standardowych 16-bitowych wartości wyglądu, która pozwala centralnemu urządzeniu wyświetlić odpowiednią ikonę. Dane specyficzne dla producenta przekazuje się przez manufacturer=(company_id, data_bytes).
Garstka mniej powszechnych słów kluczowych obejmuje resztę przestrzeni flag rozgłaszania:
connectable=False– tryb tylko rozgłaszania (połączenie nigdy nie jest przyjmowane). Właściwy wybór dla ładunków w stylu beacon.limited_disc=True– użyj flagi ograniczonej wykrywalności zamiast ogólnej wykrywalności; niektóre systemy operacyjne traktują te dwie odmiennie w swoim interfejsie parowania.adv_data/resp_data– surowe bajty, jeśli aplikacja potrzebuje pełnej kontroli nad układem.timeout_ms– zrezygnuj po upływie ustalonego czasu. Domyślnie rozgłaszanie trwa w nieskończoność.
Gdy urządzenie centralne się łączy, aioble.advertise() zwraca wynikowe aioble.DeviceConnection. W tym momencie urządzenie peryferyjne przestaje rozgłaszać.
11.9.3. Obsługa jednego klienta¶
Główna pętla urządzenia peryferyjnego zazwyczaj wygląda tak:
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 sprawia, że czyszczenie po rozłączeniu jest automatyczne. disconnected() to korutyna, która zawiesza się, dopóki jedna ze stron nie zakończy połączenia – czysty sposób na utrzymanie urządzenia peryferyjnego w trybie obsługi, dopóki urządzenie centralne nie zniknie, a następnie powrót do rozgłaszania w kolejnej rundzie.
11.9.4. Aktualizowanie charakterystyki¶
Urządzenie peryferyjne aktualizuje lokalną bazę danych GATT za pomocą aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
To zmienia wartość, którą zwróciłoby kolejne read od dowolnego klienta. Samo w sobie nie wypycha nowej wartości – subskrybowany klient nie zobaczy niczego, dopóki klient nie odpyta lub urządzenie peryferyjne nie wyśle jawnego powiadomienia.
Strona wypychania to pojedyncze słowo kluczowe w tym samym wywołaniu:
temp_char.write(temp_bytes, send_update=True)
send_update=True powiadamia (lub wskazuje) każdego klienta, który zasubskrybował tę charakterystykę. Większość kodu w stylu czujnika znajduje się w zadaniu przypisanym do połączenia, które w pętli odczytuje czujnik i zapisuje wartość z send_update=True mniej więcej co sekundę:
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()
Jeśli wolisz skierować powiadomienie do jednego konkretnego klienta zamiast do całego zbioru subskrybentów (powiedzmy odpowiedź prywatną dla połączenia na polecenie tego klienta), aioble.Characteristic.notify() i indicate() przyjmują argument DeviceConnection oraz opcjonalny ładunek.
11.9.5. Odbieranie zapisów¶
Kierunek odwrotny – klient zapisujący do charakterystyki – staje się dostępny, gdy charakterystyka zostanie skonstruowana z write=True lub write_no_response=True. Urządzenie peryferyjne oczekuje na kolejny zapis za pomocą 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)
Bez capture=True written() zwraca jedynie zapisujące połączenie; nowa wartość znajduje się w buforze zaplecza charakterystyki, a aplikacja pobiera ją za pomocą read(). Jeśli drugi zapis nadejdzie, zanim aplikacja odczyta pierwszy, druga wartość nadpisuje pierwszą w buforze, a pierwotna wartość zostaje utracona – written() nadal wybudza aplikację, ale tylko raz na „jest coś nowego”, a nie raz na zapis.
Słowo kluczowe capture=True to naprawia. Każdy przychodzący zapis jest dołączany do kolejki obejmującej cały moduł, a written() zwraca krotkę (connection, data) dla każdego pojedynczego zapisu – pętla aplikacji widzi każdy z nich dokładnie raz, w kolejności przybywania. Dwie praktyczne konsekwencje:
Kolejka jest ograniczona i jest współdzielona przez wszystkie charakterystyki z włączonym przechwytywaniem na urządzeniu. Krótkie serie następujących po sobie zapisów są tolerowane; trwałe przepełnienie (zapisy przybywające szybciej, niż aplikacja je opróżnia) po cichu odrzuca najstarsze wpisy w kolejce, a gwałtowny ruch na jednej charakterystyce może wyprzeć oczekujące wpisy z innej.
Wybierz
capture=Truedla zapisów w stylu poleceń, gdzie każda wartość ma znaczenie. Pozostaw je wyłączone dla charakterystyk w stylu stanu, gdzie interesuje nas tylko najnowsza wartość.
Jeśli odczyt od klienta ma być obsłużony przez kod uruchamiany na żądanie, a nie przez wartość statyczną, nadpisz on_read(). Metoda jest wywoływana synchronicznie, gdy nadejdzie odczyt; zwróć 0, aby zezwolić na odczyt (zostanie wysłana bieżąca wartość z write()), lub niezerowy kod błędu ATT, aby go odrzucić:
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)
Wywołanie zwrotne próbkuje czujnik i aktualizuje wartość charakterystyki tuż przed tym, jak stos GATT obsłuży odczyt, więc klient zawsze widzi świeże dane. Ograniczenie częstotliwości powstrzymuje klienta przed odpytywaniem czujnika szybciej, niż można go próbkować – każdy odczyt w trakcie jednosekundowego okresu wyciszenia jest odbijany jako błąd ATT Read Not Permitted zamiast nieaktualnej wartości.
11.9.5.1. Większe bufory zaplecza – BufferedCharacteristic¶
Bufor zaplecza zwykłej Characteristic ma szerokość 20 bajtów – praktyczny limit przy domyślnym MTU wynoszącym 23 bajty. Klient, który zapisze więcej niż tyle do zwykłej charakterystyki, otrzyma obciętą wartość. Dla większych wartości przychodzących lub do kolejkowania następujących po sobie zapisów, które pętla aplikacji nadrobi później, zadeklaruj charakterystykę jako BufferedCharacteristic i z góry wybierz rozmiar bufora:
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)
Dwa pokrętła odróżniają ją od zwykłej Characteristic:
max_lento rozmiar bufora zaplecza w bajtach. Dobierz go tak, aby pasował do największego pojedynczego zapisu, jakiego oczekuje się od klienta (po negocjacji MTU).append=Truesprawia, że kolejne zapisy dołączają się do bufora, zamiast go nadpisywać – przydatne przy odbieraniu wartości, która przybywa w kilku zapisach (fragmenty aktualizacji oprogramowania układowego, wiersze dziennika). Przyappend=Falsebufor zachowuje się jak zwykła charakterystyka, tylko szersza.
Wszystkie pozostałe flagi konstruktora (read, write, notify, indicate, capture, initial) są przekazywane bez zmian do bazowej charakterystyki.
11.9.6. Standardowe usługi i identyfikatory UUID przypisane przez SIG¶
Trzymanie się identyfikatorów UUID z przypisanych numerów (0x180F dla Battery Service, 0x181A dla Environmental Sensing, 0x180D dla Heart Rate i tak dalej) oznacza, że ogólne menu Bluetooth w telefonie lub dowolna aplikacja skanera innej firmy może rozpoznać przeznaczenie urządzenia bez żadnego niestandardowego kodu klienta. Układ bajtów wewnątrz każdej standardowej charakterystyki jest również ustalony przez specyfikację – Battery Level (0x2A19) to pojedynczy bajt 0..100; Temperature (0x2A6E) to sint16 little-endian w jednostkach 0,01 stopnia C. Dla aplikacji, które nie pasują do standardowej usługi, wygeneruj raz 128-bitowy UUID i użyj go w usługach oraz charakterystykach urządzenia.
Urządzenie peryferyjne, które publikuje wyłącznie niestandardowe identyfikatory UUID, jest nadal w porządku – potrzebuje jedynie niestandardowej aplikacji klienckiej, która zna te identyfikatory UUID.
Informacja
Wartości BLE są wszędzie little-endian – specyfikacja GATT, każda standardowa charakterystyka, każde pole rozgłaszania. Wielobajtowe liczby całkowite trafiają na łącze najpierw najmłodszym bajtem. Prefiks < w łańcuchach formatu struct jest tym, czego potrzebujesz do kodowania/dekodowania ("<h", "<H", "<I", …); użycie domyślnej natywnej kolejności bajtów na mikrokontrolerze typu little-endian akurat działa na razie, ale jawne zapisanie < to bezpieczny nawyk.
11.9.7. Radio, które za tym wszystkim stoi¶
Radio jest włączone w chwili, gdy pierwsza korutyna aioble go dotknie. Dopóki nie zostanie podłączone urządzenie centralne, urządzenie peryferyjne spędza czas, przełączając się między krótkimi seriami rozgłaszania a uśpieniem; po nawiązaniu połączenia podąża za wynegocjowanym interwałem połączenia. Urządzenie peryferyjne ponosi niewielki koszt energetyczny na każde rozgłoszenie, więc wybór interval_us w aioble.advertise() jest najbardziej bezpośrednim pokrętłem, jakim dysponuje urządzenie peryferyjne, do balansowania między opóźnieniem wykrywania a żywotnością baterii.