11.11. Каналы L2CAP

GATT – это модель ключ/значение. Операции, которые она предлагает (чтение, запись, уведомление, индикация), передают одно короткое значение за раз, а наибольшая полезная нагрузка, которую они могут нести, ограничена согласованным MTU – в лучшем случае несколько сотен байтов. Это хорошо работает для показаний датчиков, командных регистров и флагов состояния. Но это разваливается на килобайтах или мегабайтах: разбиение длинного блоба на сотни мелких записей обходится в круговые задержки, на фоне которых радиомодуль работает гораздо быстрее.

Для потоков объёмных данных – захваченного кадра, который камера передаёт телефону, образа обновления по воздуху, пакетного экспорта измерений – BLE предлагает альтернативный путь: Logical Link Control and Adaptation Protocol (протокол управления и адаптации логического канала), L2CAP. L2CAP находится между канальным уровнем и GATT и позволяет приложению заявить свой собственный ориентированный на соединение канал поверх того же радиоканала. Канал представляет собой байтовый путь с управлением потоком по кредитам, гораздо большим MTU на пакет и без обрамления GATT посередине.

11.11.1. Когда использовать L2CAP

Каналы L2CAP – это правильный инструмент, когда:

  • Объём передачи превышает несколько сотен байтов.

  • Обе стороны заранее знают, что будет использоваться канал L2CAP (он не отображается в рекламной полезной нагрузке; клиент должен узнать номер protocol/service multiplexer, или PSM, канала по внешнему каналу).

  • Приложение готово отказаться от удобств GATT: нет встроенной адресации по UUID, нет обнаружения клиентов через стандартные приложения, нет уведомлений.

Наиболее распространённый случай в приложениях на основе aioble – перемещение бинарного блоба между двумя программами, которые обе знают о соглашении по PSM: пользовательский протокол камера-телефон, пара камер openmv, общающихся друг с другом, внутренний путь обновления прошивки в рамках GATT-сервиса периферийного устройства.

Для всего остального оставайтесь на GATT. Короткое состояние, управляющий регистр, показание датчика – всё это относится к характеристике.

11.11.2. Установление канала

L2CAP работает поверх существующего aioble.DeviceConnection, поэтому процесс обнаружения / рекламы / подключения на уровне GAP точно такой же, как и для GATT. Как только обе стороны удерживают подключение, одна сторона слушает на PSM, а другая подключается к ней.

PSM – это просто небольшое целое число. Bluetooth SIG резервирует нижнюю часть диапазона для стандартизированного использования (0x0001-0x007F); для каналов конкретного приложения используйте номер из динамического диапазона (0x0080-0x00FF для фиксированных PSM, начиная с 0x0040 обычно свободно для пользовательского применения). Обе стороны должны заранее согласовать значение.

MTU на канале L2CAP – это наибольший единичный SDU (Service Data Unit, блок служебных данных), который любая из сторон передаст за один send() – а не MTU радиоканала BLE. Aioble автоматически фрагментирует более крупные полезные нагрузки. BLE-хост камеры ограничивает MTU L2CAP до 1017 байтов; 512 – разумное значение по умолчанию, оставляющее запас на обеих сторонах без излишнего расхода ОЗУ.

На стороне слушателя (например, камера как периферийное устройство):

async def serve_l2cap(connection, image_bytes):
    channel = await connection.l2cap_accept(psm=0x80, mtu=512)
    async with channel:
        # image_bytes is a bytearray -- e.g. csi0.snapshot().bytearray()
        # or a compressed JPEG buffer. send() fragments into MTU-sized
        # chunks automatically and awaits flow-control credits between.
        await channel.send(image_bytes)
        await channel.flush()

На стороне подключающегося (например, телефон или центральное устройство):

async def open_l2cap(connection, total_bytes):
    channel = await connection.l2cap_connect(psm=0x80, mtu=512)
    async with channel:
        image_bytes = bytearray(total_bytes)
        view = memoryview(image_bytes)
        received = 0
        while received < total_bytes:
            n = await channel.recvinto(view[received:])
            if n == 0:
                break
            received += n
        return image_bytes

l2cap_accept() блокируется, пока пир не подключится (или не сработает timeout_ms); l2cap_connect() блокируется, пока слушатель не примет подключение (или не произойдёт сбой). Оба возвращают aioble.L2CAPChannel – сам по себе асинхронный контекстный менеджер, закрывающий канал при выходе.

11.11.3. Отправка и приём

Две основные операции на канале – send() (записывает байты пиру) и recvinto() (читает в заранее выделенный буфер). Обе являются корутинами.

  • send() фрагментирует буфер на части размером с MTU и ожидает между ними кредиты управления потоком канального уровня. Длинная отправка – это одно await с точки зрения приложения; внутренне она может ставить в очередь множество пакетов и приостанавливаться всякий раз, когда у пира заканчиваются кредиты приёма.

  • recvinto() заполняет переданный буфер тем, что доступно (вплоть до MTU канала), и возвращает количество байтов. Ожидает, если ничего не доступно.

  • available() синхронно возвращает True, если есть готовые буферизованные данные – полезно для опроса без приостановки.

  • flush() ожидает, пока любая незавершённая отправка не будет полностью передана контроллеру.

Каналы L2CAP подобны потоку в том смысле, что байты приходят по порядку и без потерь, но границы одного send сохраняются – каждый SDU выходит из одного recvinto. Это отличается от TCP, где границы одного send() могут размазаться по нескольким вызовам recv().

11.11.4. Обработка отключения

Канал исчезает при трёх условиях: любая из сторон вызывает disconnect(), разрывается лежащее в основе GAP-подключение или приходит отключение на уровне L2CAP. Активные операции возбуждают aioble.L2CAPDisconnectedError. Как и на стороне GATT, это проявляется как исключение в корутине, которая ожидала, а блок async with channel корректно завершается.

Если канал становится недоступным из-за отключения на уровне GAP, приложение возвращается к рекламе или сканированию так же, как оно сделало бы это при отключении GATT.

11.11.5. Затраты памяти

Большие MTU и более длинные очереди используют больше ОЗУ на обеих сторонах. MTU в 512 байтов плюс приёмный буфер на канал составляют около 1 КБ на канал – что не бесплатно на маленькой камере, если несколько каналов открыты одновременно. Придерживайтесь одного канала на подключение и выбирайте MTU, соответствующий ожидаемому размеру сообщения; значения по умолчанию – один L2CAPChannel на DeviceConnection – достаточно для большинства приложений.

L2CAP – это предохранительный клапан BLE. GATT – то, к чему почти каждое приложение обращается в первую очередь, и остальные примеры центральных / периферийных устройств в этом разделе придерживаются GATT. API с каналами – это ответ на случай, когда приложение перерастает модель ключ/значение.