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() – це найпряміший важіль для балансування між затримкою виявлення та ресурсом батареї.