Embedded HandbookПриложения
Все разделы › XXIII. Приложение к прибору: Dart и Flutter
STM32F103, STM32F407, STM32G431, STM32L431 · уровень средний · 6 мин чтения
Открыть в интерактивном справочнике

189. BLE и USB-serial: транспорт до прибора, MTU, очередь записей, разрешения

Сеть из раздела 188 хороша, когда прибор уже подключён к чему-то. Но чаще всё начинается иначе: прибор лежит на столе, рядом телефон или ноутбук, и связи, кроме кабеля и Bluetooth, нет.

Здесь всё держится на одном решении: транспорт — это интерфейс. Приложение не знает, что под ним — кабель, BLE или сокет; разбор кадров, таймауты и переподключение остаются теми же, что в разделе 186.

Числа ниже получены на модели канала: она ведёт себя как платформенный BLE — режет записи по MTU и принимает по одной за раз. Сам стек BLE проверяется только на устройстве, и об этом честно сказано в 189.5.

Как это называется в документации

Оригинал Перевод
transport транспорт: канал до прибора
GATT characteristic характеристика: ячейка для чтения, записи, уведомлений
notification / indication уведомление без подтверждения и с подтверждением
MTU наибольший размер одной посылки
write with / without response запись с подтверждением и без
pairing / bonding сопряжение и его запоминание
runtime permission разрешение, которое спрашивают во время работы
CDC ACM, virtual COM port последовательный порт поверх USB
DTR / RTS сигналы управления портом

189.1 Один интерфейс — разные каналы

abstract interface class Transport {
  Stream<List<int>> get input;
  Future<void> send(List<int> bytes);
  Future<void> close();
}

Больше приложению не нужно ничего. Что за этим стоит:

Канал Где работает Чем ограничен
USB-serial (CDC) настольные системы, Android с OTG нужен кабель и права на устройство
BLE телефоны, настольные системы, веб с оговорками MTU, одна запись за раз, разрешения
TCP / WebSocket всюду, где есть сеть нужен адрес и сеть до прибора

Дальше разбираем BLE — он самый капризный, и именно его особенности чаще всего протекают в код приложения.

Транспорт до прибора: один интерфейс, три канала


189.2 MTU режет кадры

BLE передаёт не поток, а посылки. В одну запись помещается MTU минус три байта заголовка ATT (раздел 158.5): при стандартном MTU 23 — 20 байт. Ниже «MTU 20» — именно эти 20 байт полезной части, и mtu в коде — тоже она; договорившись о MTU 247, режут по 244. Всё, что длиннее, приложение режет само:

for (int i = 0; i < bytes.length; i += mtu) {
  final int end = i + mtu > bytes.length ? bytes.length : i + mtu;
  chunks++;
  await _write(bytes.sublist(i, end));     // ждём подтверждения каждого куска
}

Кадр с полезной частью 32 байта — это 35 байт вместе со стартом, длиной и CRC. При MTU 20 он уезжает двумя посылками:

Что проверено Значение
длина кадра 35 байт
посылок при MTU 20 2
их размеры 20 и 15 байт

Отсюда правило протокола: кадр должен переживать разрезание в любом месте. Наш формат это умеет с раздела 185 — разборщик хранит состояние между посылками, и ему всё равно, как байты разбились по дороге.

Кадр из старта, длины, полезной части 32 байта и CRC — 35 байт целиком; при MTU 20 он уезжает двумя посылками по 20 и 15 байт, и разрез приходится посреди полезной части — разборщик хранит состояние между посылками


189.3 Одна запись за раз — иначе «занято»

На Android одновременно выполняется одна операция GATT на всё соединение — запись, чтение, подписка: пока предыдущая не подтверждена, следующая отвергается. iOS ставит записи в очередь сама, но при записи без ответа надо ждать готовности. Поэтому очередь делают одну на соединение и пропускают через неё все операции. Приложение, которое шлёт команды «когда захочет», теряет их:

Как отправляем десять команд Доставлено Ошибок «занято»
без очереди, все сразу 1 9
через очередь 10 0

Очередь — четыре строки: каждая новая запись пристраивается в хвост предыдущей.

final Future<void> started = _tail.then((_) => job());
_tail = started.catchError((Object _) {});   // очередь не должна рассыпаться от одной ошибки

catchError в хвосте обязателен: без него первая же неудачная запись рвёт цепочку, и все следующие команды молча не уходят.

Проверка сверяет ещё и порядок: десять команд приходят в том же порядке, в каком их отправили, — для команд прибору это важнее скорости.


189.4 Уведомления приходят кусками

Обратное направление устроено так же: прибор шлёт уведомления, и кадр приходит частями. Поверх транспорта работает тот же разборщик:

Stream<FrameEvent> transportFrames(Transport t) => frames(t.input);

В проверке кадр приходит двумя уведомлениями (3 байта и остаток) и собирается в один FrameReady. Ни одной строчки, особенной для BLE, в разборе нет.


189.5 Разрешения и ограничения платформ

Это та часть, которую нельзя проверить в контейнере, — и именно она съедает дни:

Платформа Что нужно знать
Android с версии 12 — разрешения BLUETOOTH_SCAN и BLUETOOTH_CONNECT (геолокация не нужна при флаге neverForLocation); до 12 — разрешение на геолокацию и включённая служба геолокации в настройках. Спрашиваются во время работы; без них поиск возвращает пустой список без ошибки
iOS нужны описания в Info.plist; фоновая работа отдельно оговаривается
веб Web Bluetooth только по действию пользователя и только на HTTPS; часть браузеров не поддерживает вовсе
настольные BLE есть не везде, зато есть USB-serial; на Linux — права на устройство

Общее правило: на экране всегда видно состояние — ищем, подключены, нет разрешения. Молчащий экран без объяснения — самая частая жалоба на приложения к приборам.


189.6 USB-serial: что отличается

Кабель проще BLE во всём, кроме мелочей, которые ломают связь:

Для приложения это тот же Transport: меняется реализация send и источник input, остальное — общий код.


189.7 Грабли

Кадр шлют одной записью. При MTU 20 платформа обрежет или откажет; резать должно приложение.

Команды шлют без очереди. Девять из десяти теряются с ошибкой «занято», и обычно её никто не читает.

Очередь без catchError. Одна неудачная запись — и дальше молчание навсегда.

Разбор переписан «под BLE». Второй разборщик с теми же ошибками; кадр обязан переживать разрезание в любом месте.

Разрешение не спрошено. Поиск возвращает пустой список, а приложение показывает «приборов нет».

Открытие порта сбрасывает прибор. DTR поднят — плата перезагрузилась; в журнале это выглядит как «прибор сам перезапустился».


189.8 Итоги

Дальше: раздел 189а «Обновление прошивки из мобильного приложения: от кнопки в Flutter до загрузчика STM32».

Открыть в интерактивном справочнике