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 — разборщик хранит состояние между посылками, и ему всё равно, как байты разбились по дороге.
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 во всём, кроме мелочей, которые ломают связь:
- Скорость и формат важны для переходника USB-UART и порта отладчика: за ними настоящий UART, и несовпадение даёт мусор, который разборщик честно отвергает по CRC. У виртуального порта на USB самого STM32 скорость условна: прошивка её лишь получает, а байты идут со скоростью USB.
- DTR и RTS. Некоторые платы сбрасываются при подъёме DTR: открыли порт — прибор перезагрузился.
- Буфер драйвера отдаёт байты как придётся: два кадра в одном чтении и половина кадра в другом — ровно то, к чему разборщик готов.
- Права на устройство. На Linux нужен доступ к
/dev/ttyACM0, на Windows — номер COM-порта, который меняется от разъёма к разъёму.
Для приложения это тот же Transport: меняется реализация send и
источник input, остальное — общий код.
189.7 Грабли
Кадр шлют одной записью. При MTU 20 платформа обрежет или откажет; резать должно приложение.
Команды шлют без очереди. Девять из десяти теряются с ошибкой «занято», и обычно её никто не читает.
Очередь без catchError. Одна неудачная запись — и дальше молчание
навсегда.
Разбор переписан «под BLE». Второй разборщик с теми же ошибками; кадр обязан переживать разрезание в любом месте.
Разрешение не спрошено. Поиск возвращает пустой список, а приложение показывает «приборов нет».
Открытие порта сбрасывает прибор. DTR поднят — плата перезагрузилась; в журнале это выглядит как «прибор сам перезапустился».
189.8 Итоги
- Транспорт — интерфейс из трёх методов; разбор, таймауты и переподключение из 186 работают поверх любого канала.
- BLE режет записи по MTU: кадр 35 байт уходит двумя посылками 20 и 15.
- Запись строго одна за раз: десять команд без очереди дают 1 доставленную и 9 ошибок «занято», через очередь — 10 из 10 по порядку.
- Хвост очереди гасит ошибку, иначе одна неудача рвёт цепочку навсегда.
- Разрешения и ограничения платформ проверяются только на устройстве — экран обязан показывать состояние, а не молчать.
Дальше: раздел 189а «Обновление прошивки из мобильного приложения: от кнопки в Flutter до загрузчика STM32».