11.9. Робота у ролі периферійного пристрою#
Найпоширеніший шаблон BLE на стороні камери – виступати у ролі периферійного пристрою: публікувати невелику базу даних GATT, сповіщати про себе через рекламу, приймати підключення від телефону або супутнього пристрою та передавати значення з’єднаному клієнту.
11.9.1. Побудова бази даних GATT#
Перше, що робить периферійний пристрій при запуску – ще до увімкнення радіо – це формує базу даних, яку він планує оприлюднити: створює об’єкти для кожного сервісу та характеристики, а потім реєструє їх:
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)
Кожен aioble.Characteristic прив’язується до свого сервісу просто при конструюванні з сервісом як першим аргументом. Булеві іменовані аргументи (read, write, write_no_response, notify, indicate) визначають, які GATT-операції клієнту буде дозволено виконувати; значення False (типово) означає, що відповідний біт властивості не встановлено.
aioble.register_services() фіксує зібране дерево у GATT-сервері. Викликається один раз до запуску будь-якого aioble.advertise(); повторний виклик замінює попередню базу даних.
11.9.2. Реклама (advertising)#
Після того як база даних сформована, запуск реклами – це один виклик корутини, що очікує на підключення:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Іменовані аргументи безпосередньо відображаються на поля рекламного пакету. name – поле локального імені; services – список UUID-сервісів, які підтримує пристрій (сканер на стороні телефону може фільтрувати за ними); appearance – підказка зі стандартних 16-бітних значень зовнішнього вигляду, що дозволяє центральному пристрою відображати зрозумілу іконку. Дані від виробника передаються через manufacturer=(company_id, data_bytes).
Кілька рідше використовуваних ключових слів охоплюють решту бітів рекламного прапора:
connectable=False– режим лише трансляції (підключення ніколи не приймається). Правильний вибір для маяків.limited_disc=True– використовувати прапор обмеженої видимості замість загальної видимості; деякі операційні системи по-різному обробляють їх у своєму інтерфейсі сполучення.adv_data/resp_data– необроблені байти, якщо застосунку потрібен повний контроль над структурою пакету.timeout_ms– зупинити рекламу після фіксованого часу. Типово – рекламувати нескінченно.
Коли центральний пристрій підключається, aioble.advertise() повертає отримане aioble.DeviceConnection. На цьому етапі периферійний пристрій припиняє рекламу.
11.9.3. Обслуговування одного клієнта#
Основний цикл периферійного пристрою зазвичай виглядає так:
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 автоматизує очищення після відключення. disconnected() – корутина, яка призупиняється до тих пір, поки одна зі сторін не завершить з’єднання – чистий спосіб тримати периферійний пристрій активним, доки центральний не зникне, а потім повернутися до реклами у наступному раунді.
11.9.4. Оновлення характеристики#
Периферійний пристрій оновлює локальну базу даних GATT за допомогою aioble.Characteristic.write()
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Це змінює значення, яке поверне наступний read від будь-якого клієнта. Саме по собі це не відправляє нове значення – підписаний клієнт нічого не побачить, поки або клієнт не зробить опитування, або периферійний пристрій не надішле явного сповіщення.
Сторона відправлення – це одне ключове слово у тому самому виклику:
temp_char.write(temp_bytes, send_update=True)
send_update=True сповіщає (або індикує) кожного клієнта, що підписався на цю характеристику. Більшість коду типу «датчик» існує у задачі на кожне підключення, що зациклюється, зчитуючи датчик та записуючи значення з send_update=True кожну секунду або близько того:
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()
Якщо ви хочете надіслати сповіщення одному конкретному клієнту, а не всім підписаним (наприклад, приватна відповідь на команду цього клієнта), aioble.Characteristic.notify() і indicate() приймають аргумент DeviceConnection та необов’язкові дані.
11.9.5. Отримання записів#
Інший напрямок – клієнт записує у характеристику – стає доступним, коли характеристика створена з write=True або write_no_response=True. Периферійний пристрій очікує наступного запису за допомогою 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)
Без capture=True, written() повертає лише з’єднання, що записало; нове значення зберігається у буфері характеристики і застосунок зчитує його за допомогою read(). Якщо другий запис надходить до того, як застосунок прочитав перший, друге значення перезаписує перше у буфері і оригінальне значення втрачається – written() все одно пробуджує застосунок, але лише один раз на «є щось нове», а не один раз на кожен запис.
Ключове слово capture=True вирішує цю проблему. Кожен вхідний запис додається до глобальної черги модуля, і written() повертає кортеж (connection, data) для кожного окремого запису – цикл застосунку бачить кожен рівно по одному разу, у порядку надходження. Два практичні наслідки:
Черга обмежена і є спільною для всіх характеристик із capture на пристрої. Короткі серії послідовних записів допускаються; тривале переповнення (записи надходять швидше, ніж застосунок їх обробляє) мовчки скидає найстаріші записи у черзі, а інтенсивний трафік на одній характеристиці може витісняти очікувані записи з іншої.
Використовуйте
capture=Trueдля команд, де важливо кожне значення. Залишайте вимкненим для характеристик-станів, де цікаве лише останнє значення.
Якщо зчитування від клієнта має оброблятися кодом, що виконується на вимогу, а не статичним значенням, перевизначте on_read(). Метод викликається синхронно при надходженні зчитування; поверніть 0, щоб дозволити зчитування (буде надіслано поточне значення з write()), або ненульовий код помилки ATT, щоб відхилити його:
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)
Зворотний виклик зчитує датчик та оновлює значення характеристики безпосередньо перед тим, як стек GATT обслуговує зчитування, тому клієнт завжди бачить свіжі дані. Обмеження швидкості зупиняє клієнта від надмірного опитування датчика – будь-яке зчитування у межах однієї секунди після попереднього повертається як помилка ATT Read Not Permitted замість застарілого значення.
11.9.5.1. Більші буфери – BufferedCharacteristic#
Буфер звичайного Characteristic має ширину 20 байт – практична межа при типовому MTU у 23 байти. Клієнт, що записує більше у звичайну характеристику, отримає значення, що урізане. Для більших вхідних значень або для буферизації послідовних записів, які цикл застосунку обробить пізніше, оголосіть характеристику як BufferedCharacteristic та вкажіть розмір буфера заздалегідь:
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)
Два параметри відрізняють її від звичайного Characteristic:
max_len– розмір буфера у байтах. Вибирайте відповідно до найбільшого одиничного запису, який очікується від клієнта (після узгодження MTU).append=Trueробить послідовні записи дописуванням у буфер замість перезапису – корисно для отримання значення, що надходить кількома записами (фрагменти оновлення мікропрограми, рядки журналу). Зappend=Falseбуфер поводиться як звичайна характеристика, тільки ширша.
Усі інші прапори конструктора (read, write, notify, indicate, capture, initial) без змін передаються базовій характеристиці.
11.9.6. Стандартні сервіси та UUID, призначені SIG#
Дотримання UUID за призначеними номерами (0x180F для Battery Service, 0x181A для Environmental Sensing, 0x180D для Heart Rate тощо) означає, що стандартне Bluetooth-меню телефону або будь-який сторонній сканер може визначити призначення пристрою без спеціального клієнтського коду. Структура байтів усередині кожної стандартної характеристики також фіксована специфікацією – Battery Level (0x2A19) – це один байт 0..100; Temperature (0x2A6E) – sint16 у форматі little-endian в одиницях 0,01 °C. Для застосунків, що не відповідають стандартному сервісу, згенеруйте 128-бітний UUID один раз і використовуйте його у сервісах та характеристиках пристрою.
Периферійний пристрій, що публікує лише власні UUID, цілком прийнятний – йому просто потрібен спеціальний клієнтський застосунок, що знає ці UUID.
Примітка
Значення BLE є little-endian скрізь – у специфікації GATT, у кожній стандартній характеристиці, у кожному рекламному полі. Багатобайтові цілі числа передаються молодшим байтом першим. Префікс < у рядках формату struct – це те, що потрібно для кодування/декодування ("<h", "<H", "<I", …); використання типового власного порядку байтів на little-endian мікроконтролері випадково працює зараз, але явне зазначення < – це безпечна звичка.
11.9.7. Радіо, що лежить в основі#
Радіо вмикається щойно перша корутина aioble торкається його. Поки центральний пристрій не підключений, периферійний чергує між короткими рекламними серіями та сном; після підключення він слідує узгодженому інтервалу підключення. Периферійний пристрій витрачає невелику кількість енергії на кожну рекламу, тому вибір interval_us у aioble.advertise() – це найпряміший важіль для балансування між затримкою виявлення та ресурсом батареї.