Приложение-рация для поискового отряда ЛизаАлерт: голос, карта и кабинет координатора за двое суток

Как мы сделали для регионального отряда ЛизаАлерт своё приложение-рацию на Android и кабинет координатора: голос через свой сервер, карта с задачами групп, обновления разницей. Цифры нагрузки и баги, которые нашлись в первые дни.

Региональный поисково-спасательный отряд ЛизаАлерт говорил на поисках через Mumble. Он работает, но это голосовой чат для игроков, а не инструмент штаба: координатор не видит, где люди, не может выдать группе участок леса и не знает, кто сейчас в эфире. Нас попросили сделать своё приложение-рацию и кабинет координатора, которые живут на сервере отряда. Первый коммит появился 26 октября в два часа ночи. К утру 28-го в репозитории было 57 коммитов, а приложение дошло до версии 0.7.0, двадцать пятого выпуска.

Получилось две части. Приложение для Android передаёт голос по кнопке, причём это может быть кнопка на экране, аппаратная кнопка PTT на телефонах-рациях или кнопка гарнитуры. В фоне оно отправляет координаты и показывает на карте задачу своей группы. Кабинет координатора открывается в браузере. В нём карта со всеми людьми, их треками и задачами групп, эфир прямо из браузера, каналы, группы, роли с правами, записи эфира, журнал действий и API для внешних систем.

Кабинет координатора LAVoice: слева каналы и группы с людьми, справа карта с участками групп, треками и меткой человека в эфире
Кабинет координатора на демо-данных: позывные, люди и поиск выдуманы. Оранжевая метка с волнами - тот, кто сейчас говорит. Прямоугольники - участки групп из GPX, пунктир - треки.

Приложение

Рация должна работать в кармане с погашенным экраном, иначе это не рация. Поэтому голос, координаты и связь живут в службе переднего плана. При первом входе приложение проводит человека по разрешениям: микрофон, местоположение, уведомления и работа без ограничений батареи. Пока обязательные пункты не включены, дальше оно не пускает. Без последнего пункта Android через несколько минут после выключения экрана сам обрывает связь, и человек в лесу остаётся глухим, не зная об этом. На телефонах с китайскими прошивками приложение открывает их собственные настройки энергосбережения, потому что системной галочки там мало.

Говорить можно экранной кнопкой, кнопкой проводной или Bluetooth-гарнитуры и любой аппаратной кнопкой: на рациях с Android это обычно отдельная клавиша PTT сбоку. Нажимаешь «назначить», потом нужную кнопку. Режим выбирается: держать, пока говоришь, или нажал и отпустил. Аппаратная кнопка работает и при свёрнутом приложении. Для этого служит служба специальных возможностей, которая видит только нажатия клавиш, а содержимое экрана не читает.

Слева настройка телефона при первом входе: микрофон, местоположение, уведомления, работа без ограничений батареи. Справа настройка кнопки передачи: аппаратная кнопка, режим держать или нажал-отпустил, кнопка гарнитуры
Первый вход и настройка кнопки передачи. Демо-сервер, люди и поиск выдуманы.

Главный экран называется «Эфир». Сверху поиск и своя группа, под ними качество связи: пинг, джиттер, потери и путь, по которому сейчас идёт голос. Когда кто-то говорит, вместо «Эфир свободен» появляются его позывной, имя и таймер передачи. Звуки мы синтезируем в коде по таблице сигналов раций Motorola, чтобы они были привычны тем, кто ходил с настоящей рацией. Нажатие отвечает трелью как у MOTOTRBO, «занято» звучит тремя короткими низкими, конец передачи двумя нотами вниз, а за 4 секунды до предела длины передачи раздаётся короткий низкий сигнал. Экран подстраивается от рации с дисплеем около 240×320, где остаются только кнопка, канал и кто говорит, до планшета.

Главный экран приложения LAVoice: слева эфир свободен, справа говорит ЛИС-1 Денис, таймер 0:03, сверху пинг, джиттер и потери
Эфир свободен и эфир занят. Строка «запасной путь через 443» значит, что голос сейчас идёт не по UDP, а через WebSocket.

Участник видит только своё: свои каналы, людей своего канала на карте, задачу своей группы и находки канала. Всех людей, все каналы и записи эфира видит кабинет координатора, и то по правам роли. Для поиска это важно не из секретности. Человеку в лесу нужен его участок, а не вся операция.

Находки появились в версии 0.7.0 в ночь на 28 октября: координатор попросил их ночью, и через несколько часов они уже были в приложении. На карте есть кнопка «Отметить находку». Точка ставится там, где стоит телефон, к ней добавляется подпись и до трёх фото. Фото ужимается до 1600 пикселей, около 300 КБ, чтобы дойти по слабой связи. Координатор видит находку на карте сразу, со звуком и снимком.

Слева форма Отметить находку: координаты, подпись, фото. Справа карта приложения с участком группы и карточкой находки Синяя куртка у старой вырубки
Отметка находки и её карточка на карте. Снято в эмуляторе, он не рисует подписи на карте, на телефоне у точек видны позывные.
Кабинет координатора: на карте метки находок, открыта карточка с фото синей куртки, координатами и точностью
Та же находка в кабинете координатора: кто отметил, когда, координаты с точностью и фото.

Сервер на одном ядре

Сервер - один бинарник на Go и PostgreSQL. Машина у отряда скромная: одно ядро, 4,8 ГБ памяти, 9 ГБ диска. Поэтому никаких очередей сообщений, контейнеров и отдельных сервисов, всё в одном процессе. Кабинет написан на обычном JavaScript без сборщика и фреймворка, около двух с половиной тысяч строк. Серверная часть на Go заняла 9 748 строк, из них почти две тысячи это тесты. Приложение - 5 538 строк Kotlin и обвязки на C к Opus, RNNoise и zstd.

Одна особенность сети сразу повлияла на устройство. Отряд выходит в интернет через один белый адрес, и при пробросе портов роутер подменяет адрес отправителя на свой. Сервер видит все подключения с одного внутреннего адреса. Значит, нельзя различать голосовые сессии по адресу и порту, как делает большинство UDP-серверов. Сессию определяет ключ в самом пакете. И блокировку подбора пароля пришлось вешать на логин, а не на IP, иначе одна ошибка отрезала бы весь отряд.

Голос

Голос кодируется в Opus на 16 кбит/с кадрами по 40 мс, с избыточностью (FEC): потерянный кадр восстанавливается из следующего. На телефоне перед кодеком стоит шумодав RNNoise и своё выравнивание громкости. Без него Samsung в системном режиме «голосовая связь» отдавал речь около -40 дБ, собеседника было почти не слышно.

Основной путь - свой протокол поверх UDP. Ключ сессии телефон получает через API вместе с «билетом» на вход, пакеты шифруются ChaCha20-Poly1305, повтор старых пакетов отсекает окно на 64 номера. Если оператор режет UDP, а в лесу на мобильной связи такое бывает, телефон переходит на WebSocket через порт 443. Браузер координатора говорит через WebSocket и WebCodecs.

Эфир в канале принадлежит одному человеку, и решает это только сервер. Остальные, нажав кнопку, получают в ответ на каждый свой кадр «занято». Каждая передача пишется отдельным файлом Ogg Opus прямо из пришедших кадров, без перекодирования, поэтому запись почти не стоит процессора.

Кабинет LAVoice на телефоне в тёмной теме: карта с группами, список каналов и большая кнопка Нажмите и держите
Кабинет на телефоне. Говорить можно прямо из браузера: зажать кнопку или пробел.

Сколько выдерживает

Нагрузку мы проверили в первую ночь, 26 октября. Клиентов изображал наш сервер в Москве: в ста каналах по одному человеку говорили без перерыва, остальные слушали, по две минуты на ступень. Мерили потери и задержку туда и обратно через сервер отряда. Важная оговорка: гоняли прототип ретранслятора, ещё без шифрования. ChaCha20 добавили позже, и отдельно его под нагрузкой пока не мерили. Процессора с ним уйдёт больше, насколько - покажет следующий замер.

Клиентов Потери Задержка p50 / p99 Процессор сервера
200 0,33% 49 / 54 мс 10-12%
400 0,66% 49 / 59 мс 12-15%
600 0,14% 49 / 54 мс 14-17%
1100 0,16% 50 / 56 мс 18-21%

Пинг между машинами 48 мс, так что сам сервер добавляет к задержке единицы миллисекунд, и от числа слушателей она не растёт. Потери тоже от нагрузки не зависят. Сначала мы грешили на сервер, но счётчики ошибок приёма UDP (UdpRcvbufErrors) были нулевыми и на сервере, и у нас, а ретранслятор отдавал ровно столько пакетов, сколько получал. Кадры терялись в сети на обратном пути.

Дальше мы ошиблись. Решили проверить тысячу одновременных передач, 2 000 клиентов, и не согласовали время с отрядом. Через тот же канал у отряда работает сеть цифровых раций. Через несколько минут её сервер стал отвечать по 4-10 секунд, и отвалились два моста. Тест остановили, мосты поднялись сами. Сервер голоса при этом был занят на треть ядра. Упёрлись в ширину канала отряда: около 17 Мбит/с в каждую сторону он уже не пропускал. Теперь большие замеры только по договорённости. Для реальной нагрузки, ста передач и тысячи слушателей, одного ядра и канала хватает с запасом.

Обновления разницей

Приложение ставится не из магазина, а с сервера отряда, и обновляется само. Целый APK весит 27 МБ, а канал у отряда небыстрый и теряет около 3% пакетов TCP. Поэтому при выкладке сервер считает zstd-патчи от трёх последних выпусков к новому. Телефон качает только разницу, собирает из неё и установленного APK новый файл и сверяет контрольную сумму. Если что-то не сошлось или версия на телефоне старше трёх последних, качается целый APK.

Чтобы разница была маленькой, classes.dex лежит в APK несжатым, а R8 не переименовывает классы. Иначе любая правка меняет сжатые байты целиком, и патч почти не отличается от полного файла. По базе сервера небольшая правка при APK 27 МБ даёт разницу 128-233 КБ, меньше процента. Выпуск 0.6.2 вышел в 740 КБ, 2,7%. Когда добавляются нативные библиотеки, экономии почти нет: переход на 0.4.0, где в приложение вошла карта MapLibre, стоил 6,8 МБ из 27.

Карта

В кабинете подложка Яндекс Карт идёт через наш сервер. Ключ живёт только на сервере, браузер получает пропуск на 12 часов, а сервер держит очередь запросов под лимит Яндекса. Есть спутник и гибрид Esri, запасной вариант OpenStreetMap. В приложении карта на MapLibre с векторной подложкой OpenFreeMap. Как только группе пришла задача, телефон сам скачивает её район, и карта работает в лесу без связи.

Задачи групп координатор загружает файлами GPX. Сервер разбирает их строго, без внешних сущностей XML, иначе через загрузку файла можно читать файлы самого сервера. Участник видит только задачу своей группы.

Меню по правому клику на поисковике: информация, скачать трек в GPX, заглушить, убрать из группы, переместить в другую группу; карта в режиме спутника
Правый клик по человеку: карточка, трек в GPX, глушение, перенос в другую группу. Подложка - спутник Esri.

Что сломалось в первые дни

Больше всего пользы дали не замеры, а жалобы координатора из рабочего чата. Он пишет, что не так, и чаще всего исправление выходит в тот же вечер, иногда прямо во время переписки.

«Эфир свободен» терялся. В первой версии телефон сам решал, занят ли канал, по приходящим пакетам. Пакет «эфир свободен» по UDP иногда терялся, телефон считал канал занятым и не давал говорить, хотя на сервере эфир давно освободился. Мы оставили одного судью, сервер. Телефон всегда шлёт кадры, а ответ на пинг раз в 5 секунд несёт текущее состояние эфира, так что потерянное сообщение чинится само.

Передача рвалась пополам. Сервер отдавал эфир, если 0,6 секунды не было кадров. На мобильной сети пауза в доставке бывает длиннее, и у слушателя посреди фразы звучал сигнал конца передачи. Подняли порог до 1,2 секунды, а «свободен» теперь повторяется дважды.

После перезапуска сервера телефон 45 секунд был глухим. Он не знал, что его сессии больше нет, и ждал тишины. Теперь сервер перед остановкой рассылает всем «переподключись», а на пакет незнакомой сессии отвечает тем же. Сессия считается мёртвой после двух пропущенных ответов на пинг, это 12 секунд вместо 45.

Яндекс-карта сама переключалась на OSM. При приближении браузер просит десятки тайлов разом. Наш ограничитель и сам Яндекс отвечали 429, а кабинет принимал это за поломку подложки. Сделали очередь с ожиданием и повторами, а уход на OSM только если Яндекс не отдал ни одного тайла.

Метки на карте то нажимались, то нет. Leaflet при первом открытии карточки через bindPopup вешает на метку свой обработчик «нажал - закрыть». Со второго нажатия карточка открывалась и тут же закрывалась. Причину нашли в исходниках Leaflet 1.9.4 и закрепили тестом в браузере.

Глушение снималось само. Сервер снимал запрет вместе с новой сессией, а сессия меняется и при обрыве связи. Стоило телефону на минуту потерять сеть, и заглушённый снова мог говорить. Теперь запрет держит и сам телефон, до перезапуска приложения.

Права

Права раздаются ролями, и координатор правит их галочками: видеть все каналы или только свои, заводить каналы и группы, глушить, скачивать треки, слушать записи, управлять ролями и токенами. Администратор всегда может всё, и назначить администратора может только администратор. Мелочь, но без неё первый же координатор с правом «роли» мог бы выдать себе всё.

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

Регистрация идёт через бота Telegram по ссылке-приглашению. С серверов в России api.telegram.org не открывается, поэтому бот ходит через SOCKS5-прокси, адрес которого задаётся в кабинете, а не зашит в код.

Что мы вынесли

Свою рацию поверх интернета сделать можно быстро, но не за счёт того, что голос простой. Голос как раз простой: Opus, UDP и маленький сервер справляются с тысячей слушателей на одном ядре. Время уходит на края: потерянный пакет «эфир свободен», пауза в мобильной сети, перезапуск сервера, роутер, который прячет адреса клиентов. Ни один из этих багов не нашёлся бы нагрузочным тестом. Их нашёл человек, который пользуется приложением каждый день и пишет в чат, что не так. Поэтому для таких проектов мы бы советовали выкатывать приложение настоящим людям с первого дня, а не полировать его месяц до первого выпуска.

Ещё в блоге