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– режим только широковещания (подключение никогда не принимается). Правильный выбор для нагрузок в стиле маяка (beacon).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=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) – это little-endian sint16 в единицах 0.01 градуса Цельсия. Для приложений, которые не укладываются в стандартный сервис, сгенерируйте 128-битный UUID один раз и используйте его во всех сервисах и характеристиках устройства.
Периферийное устройство, публикующее только пользовательские UUID, по-прежнему работоспособно – ему просто нужно специальное клиентское приложение, которое знает об этих UUID.
Примечание
Значения BLE имеют порядок байтов little-endian повсюду – в спецификации GATT, в каждой стандартной характеристике, в каждом рекламном поле. Многобайтовые целые числа передаются по проводу младшим байтом вперёд. Префикс < в строках формата struct – это то, что нужно для кодирования/декодирования ("<h", "<H", "<I", …); использование стандартного собственного порядка байтов на little-endian MCU случайно работает пока, но явное указание < – это безопасная привычка.
11.9.7. Радиомодуль, лежащий в основе всего¶
Радиомодуль включается в тот момент, когда первая сопрограмма aioble обращается к нему. Пока центральное устройство не подключено, периферийное устройство проводит время, переключаясь между короткими всплесками объявления о себе и сном; после подключения оно следует согласованному интервалу подключения. Периферийное устройство несёт небольшие энергозатраты на каждое объявление, поэтому выбор interval_us у aioble.advertise() – это самый прямой регулятор, которым периферийное устройство располагает для размена задержки обнаружения на время работы от батареи.