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