Rust, Telegram и GTFS против Петербургского транспорта
Долгое время я жил в районе, где общественный транспорт был скорее проблемой, чем благом (привет, Кудрово!). Приходилось в любую погоду ходить пешком до метро три километра, в мороз, слякоть и зной. Спустя некоторое время я переехал и появилась возможность доезжать от офиса до дома на прямом автобусе. Это звучит очень клево, но единственный маршрут ходит с большим интервалом и как попало. Приходилось или подолгу стоять на остановке, или нервно поглядывать на Яндекс.Карты и отслеживать перемещения ближайшего автобуса последние полчаса перед выходом, что сводило на нет остатки продуктивности и неслабо раздражало.
А после того, как я узнал, что на карте отображаются далеко не весь транспорт на линии, терпение лопнуло.
Постановка задачи и поиск решения
У меня есть потребность не мокнуть под дождём на остановке, потребность в ненапряжном и общественно-полезном НЕ ЭМБЕДДЭД пет-проекте. Я хочу жать одну кнопку и получать уведомление, когда мне стоит выключать паяльник и сваливать. Всё. Первым делом я стал искать готовое решение — не может же быть так, что эта очевидная проблема требует своего велосипеда. К большому удивлению, ничего подходящего не нашлось. Логичным показалось поискать какое-нибудь публичное API Яндекс.Карт, но в доках чётко сказано, что они не могут использоваться для отслеживания чего-либо. На некоторое время я отложил эту идею, а потом наткнулся на вот эту и эту ссылку.
Ага, казалось бы вот то, что нам нужно. Так как чукча не писатель, чукча — разработчик схемотехники потребительской (в основном) электроники и прошивок к устройствам, писать мобильное приложение не хотелось. К тому же, было желание сделать что-то доступное как с любой мобильной платформы, так и с ПК, простое, дубовое и с возможностью сохранить тот самый нужный мне конфиг и вызывать его нажатием одной кнопки. Лучше всего на эту роль подходил телеграм-бот и я решил написать его на Rust. Все потому, что Rust — мультипарадигменный компилируемый язык программирования общего назначения… Все потому что я использовал его для написания небольших CLI-приложений, предназначенных для шатания BLE-сервисов и характеристик на разрабатываемых мной устройствах, был доволен процессом и полученным результатом и хочу попрактиковать его в других применениях.
Развертывание сервиса предполагалось производить на домашнем Orange Pi 3, выполняющем функцию песочницы, торрентокачалки и Plex-сервера, поэтому вопрос экономии ресурсов стоял достаточно остро, что несколько отразилось на деталях реализации.
Изначальный план включал в себя три сущности: Static GTFS feed, Realtime GTFS feed и API от комитета по транспорту (здесь и далее — КТ). Предполагалось брать всю необходимую информацию об остановках из городского API, отслеживать время прибытия через Realtime GTFS, а если чего-то не хватает, то тянуть Static GTFS.
Комитет по транспорту
Казалось бы, приятнейшее дело: шли периодически запросы, парси JSON«ы, живи и процветай. Разочарование наступило достаточно быстро:
Для получения данных требуется токен, который получается при регистрации с использованием аккаунта ВК или аккаунта Яндекса. Для домашнего использования подойдет, но для публичного сервиса — не очень. Лично я бы не стал нигде регистрироваться и отдавать токен ноунейм-сервису.
Если верить API, то 220 автобус следует по маршруту «Крестовский остров — метро Горный Институт». На этом моменте я позавидовал КТ, живущему в светлом будущем. В моем 2023 «Горный Институт» по прежнему существует лишь в обещаниях, а маршрут выглядит так: «Крестовский остров — Бульвар Головнина».
Ладно, кто не без греха, на работоспособность не влияет. Попробуем найти мою остановку, «Бульвар Головнина», с ID 34395. Выполняем запрос на https://spb-transport.gate.petersburg.ru/api/stops и грустим.
На этом моменте я решил завязать с поеданием кактуса и отказаться от использования этого API. Тем более что всю необходимую информацию мы можем получить из Static GTFS, пусть и с небольшими приседаниями.
GTFS
GTFS — General Transit Feed Specification, общепринятый формат описания расписаний движения общественного транспорта и сопутствующей информации. Это набор comma-separated текстовых файлов, упакованных в периодически обновляющийся zip-архив (в нашем случае раз в сутки, примерно в два-четыре часа ночи). Что внутри:
agency.txt
— содержит информацию о транспортном агенстве, для моей задачи — бесполезен.calendar.txt
— расписание по дням недели.calendar_dates.txt
— содержит исключения для графика движения. Условно полезен, если планируется использовать расписание для подстраховки.fare_attributes.txt
— тарифы и цены. Ругаемся сквозь зубы и пропускаем.fare_rules.txt
— маршруты и их тарифные классы. Полезно, если применяется гибкая тарификация, для Питера неактуально.feed_info.txt
— информация об организации, опубликовавшей фид.frequencies.txt
— вот здесь уже пошли более интересные данные. В этом файле указывается частота следования транспорта в зависимости от времени суток. Причем эта информация указывается не для маршрута, а для каждого рейса на маршруте.operators.txt
— информация о транспортных компаниях и их ID. Может быть полезна, если вам есть на что пожаловаться.operator_routes.txt
— связывает ID маршрута с ID транспортной компании. Гораздо проще получить эту информацию с таблички за кабиной водителя.routes.txt
— первый столп нашего бота, содержит информацию о всех маршрутах. Позволяет по имени вида14
,343Э
,1Кр
,666ФхТаГН
получить числовой ID, название маршрута, вид транспорта и дополнительные флаги, показывающие, является ли маршрут кольцевым, городским или ночным. Единственное, что можно было бы улучшить — привести названия маршрута к одному виду (встречается капс, не капс, рандомное количество кавычек в названии) и убрать запятые из названия маршрута. Из-за того, что встречаются шедевры вида »304, orgp,113, «СТАНЦИЯ МЕТРО «ПРОСПЕКТ ПРОСВЕЩЕНИЯ» — 3-Й ВЕРХНИЙ ПЕР.,
5»,3, bus,0,1,0», приходится разбирать каждую строчку с двух концов, не трогая полное название маршрута.shapes.txt
— содержит координаты отрезков маршрутов. Удобно, если вы хотите отобразить их на карте.stop_times.txt
— самый жир. Почти 200 мегабайт описания, во сколько рейс придет на остановку и уйдет с неё. Заполняется для всех остановок и для всех рейсов всех маршрутов. Содержит в себе небольшой прикол: по спецификации GTFS если ваш рейс приходит на остановку в 04:20 следующего дня, то это время будет отображено как 28:20 дня текущего, в который этот рейс начался. Очень удобно, гораздо удобнее, чем использовать Unix timestamp (нет).stops.txt
— ID остановок, их названия, координаты. И снова рандомно отформатированный текст, и снова запятые внутри полей. Разбираем каждую строчку с двух сторон, преобразовываем в более-менее приличный вид.trips.txt
— связывает между собой маршруты, рейсы на них, направления движения (туда/обратно) и треки для отрисовки на карте.
Я долго думал, что лучше — сожрать немного памяти и хранить выдранные из фида данные в RAM или же делать это на диске. Решил, что скорость реакции гораздо важнее и счел бессмысленным писать на диск то, что будет актуально максимум сутки. Поэтому оставил их в RAM, а вопрос обновления данных решил кардинально: прописал в crontab рестарт сервиса каждый день незадолго до начала движения общественного транспорта. Как правило, к этому моменту данные на портале КТ уже обновляются, сервис подтягивает их и сразу после этого стартует сам бот.
Realtime GTFS
Главный ингридиент, который может решить сразу три задачи:
Выдавать информацию о задержках и изменениях маршрутов.
Отдавать оповещения о переносах остановок и непредвиденных событиях.
Самое вкусное: информацию о текущем местоположении транспорта и прогнозируемом времени прибытия.
Стоит отметить, что этот фид очень подробный и позволяет получить не только время прибытия, но и степень заполненности автобуса, его номер, текущие координаты и чуть ли не утреннее меню водителя. Фид поставляется в виде Protocol Buffer v2, ознакомиться с форматом можно здесь. В разобранном виде выглядит примерно так:
Фрагмент фида
FeedMessage {
header: FeedHeader {
gtfs_realtime_version: "1.0",
incrementality: Some(
FullDataset,
),
timestamp: Some(
1683054021,
),
},
entity: [
FeedEntity {
id: "1087",
is_deleted: None,
trip_update: Some(
TripUpdate {
trip: TripDescriptor {
trip_id: None,
route_id: Some(
"1087",
),
direction_id: None,
start_time: None,
start_date: None,
schedule_relationship: None,
},
vehicle: Some(
VehicleDescriptor {
id: Some(
"9088",
),
label: Some(
"5214",
),
license_plate: Some(
"5214",
),
},
),
stop_time_update: [
StopTimeUpdate {
stop_sequence: None,
stop_id: Some(
"15416",
),
arrival: Some(
StopTimeEvent {
delay: None,
time: Some(
1683054105,
),
uncertainty: None,
},
),
departure: None,
schedule_relationship: None,
},
],
timestamp: None,
delay: None,
},
),
vehicle: None,
alert: None,
},
],
}
Нас интересует StopTimeEvent
— он показывает, что 31 троллейбус на Петроградку прибудет ровно через 84 секунды.
Реализация бота
Получение данных
Для работы с HTTP-запросами я взял reqwest. Здесь нам требуется решить две задачи: однократное получение Static GTFS при старте и получение Realtime GTFS в цикле для пользовательского запроса. Обе задачи достаточно простые, нужно выкачать фид, сохранить его во временной директории, распаковать, найти требуемые файлы (routes.txt
, stops.txt
, trips.txt
, stop_times.txt
) и безжалостно выпотрошить нужную инфу. В принципе, если интересны только актуальные данные, то достаточно только routes.txt
и stops.txt
. Я же хотел подстраховаться на случай очередного падения портала КТ (они случаются с завидной регулярностью) и оставить возможность опираться на расписание. Чуть ниже расскажу, почему это оказалось неплохим решением.
С динамическими данными все достаточно тривиально. Посылается запрос с ID интересующей нас остановки и забирается фид. После декодирования он превращается в структуру, содержащую данные о всех рейсах, прибывающих на эту остановку в ближайшее время. Фильтруем по интересующему маршруту и направлению движения, получаем прогнозируемое время прибытия. Частоту обращения за этим фидом стоит подбирать пропорционально непредсказуемости дорожной ситуации в вашем городе. Мне показался оптимальным период обновления раз в 5 секунд. Будьте внимательны — то, что нужный маршрут был в прошлом фиде, не гарантирует его наличия в следующем! Они пропадают и появляются по не поддающейся анализу закономерности.
Логика диалогов
Беглый просмотр crates.io показал, что самый популярный и поддерживаемый фреймворк на данный момент — Teloxide. Потыкался в примеры и отрисовал примерную диаграмму взаимодействия с пользователем.
То, как это увидел Mermaid
Все эти состояния мы запихнем в одно перечисление и будем перемещаться между ними в зависимости от того, какие действия предпримет пользователь. Получилось что-то такое:
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
enum State {
#[default]
BotStart,
Start {
bot_msg: MessageId,
},
NewOrSaved,
DeleteRecord,
RouteNumber {
bot_msg: MessageId,
},
RouteDirection,
RouteStop {
route_id: RouteId,
},
RequestLeewayTime {
route_id: RouteId,
direction: String,
},
ReceiveLeewayTime {
route_id: RouteId,
stop_id: StopId,
direction: String,
bot_msg: MessageId,
},
SaveQuery {
route_id: RouteId,
stop_id: StopId,
direction: String,
leeway: u64,
},
SaveQueryName {
route_id: RouteId,
stop_id: StopId,
direction: String,
leeway: u64,
bot_msg: MessageId,
},
Search {
bot_msg: MessageId,
},
}
Внутри фигурных скобок — параметры, которые потребуется передать для корректного выполнения этого шага. На случай падежа скота сервера я сохраняю текущее состояние диалога с каждым пользователем в БД. Это позволит продолжить общение с ботом с того же самого места, даже если что-то упало и бот рестартовал.
Общение с пользователем
Для взаимодействия пользователя с ботом достаточно двух-трех инструментов:
Текстовые сообщения, для получения номера маршрута и запаса времени на путь до остановки.
Inline-кнопки, позволяющие ткнуть в нужную опцию (всё остальное).
Также можно добавить команды, например для возврата в начало или прикрутить вызов платежного API — вдруг найдутся тысячи желающих поддержать чеканной монетой?
Получившиеся три типа запросов раскидываются в схему обработки, каждому состоянию назначается функция, которая будет выполнять некое полезное действие для этого стейта, схема скармливается диспетчеру. Он будет параллельно обрабатывать обновления из всех чатов с пользователями. Стоит сразу предусмотреть механизм удаления того мусора, который попытаются навалить пользователи и не мучать бота разглядыванием стикеров и прослушиванием голосовух. В частности, если диалог находится в ожидании нажатия кнопки, а пользователь упорно пытается отправить текстовое сообщение, то оно будет безжалостно и моментально удалено, чтобы не захламлять экран. Чтобы упростить себе жизнь и не обрабатывать коллбэки от предыдущих стейтов, все сообщения от бота (кроме уведомлений о выходе) будут редактироваться. Вот так мы будем разбирать приходящие сообщения и коллбэки:
Схема обработки
fn schema() -> UpdateHandler> {
use dptree::case;
let command_handler =
teloxide::filter_command::().branch(case![Command::Start].endpoint(bot_start));
let message_handler = Update::filter_message()
.branch(command_handler)
.branch(case![State::BotStart].endpoint(bot_start))
.branch(case![State::RouteNumber { bot_msg }].endpoint(route_number))
.branch(
case![State::ReceiveLeewayTime {
route_id,
stop_id,
direction,
bot_msg
}]
.endpoint(receive_leeway_time),
)
.branch(
case![State::SaveQueryName {
route_id,
stop_id,
direction,
leeway,
bot_msg,
}]
.endpoint(save_query_name),
)
.branch(case![State::Start { bot_msg }].endpoint(delete_unexpected))
.branch(case![State::NewOrSaved].endpoint(delete_unexpected))
.branch(case![State::DeleteRecord].endpoint(delete_unexpected))
.branch(case![State::RouteDirection].endpoint(delete_unexpected))
.branch(case![State::RouteStop { route_id }].endpoint(delete_unexpected))
.branch(
case![State::RequestLeewayTime {
route_id,
direction
}]
.endpoint(delete_unexpected),
)
.branch(
case![State::SaveQuery {
route_id,
stop_id,
direction,
leeway
}]
.endpoint(delete_unexpected),
)
.branch(case![State::Search { bot_msg }].endpoint(delete_unexpected));
let callback_query_handler = Update::filter_callback_query()
.branch(case![State::Start { bot_msg }].endpoint(start))
.branch(case![State::NewOrSaved].endpoint(new_or_saved))
.branch(case![State::DeleteRecord].endpoint(delete_record))
.branch(case![State::RouteDirection].endpoint(route_direction))
.branch(case![State::RouteStop { route_id }].endpoint(route_stop))
.branch(
case![State::RequestLeewayTime {
route_id,
direction,
}]
.endpoint(request_leeway_time),
)
.branch(
case![State::SaveQuery {
route_id,
stop_id,
direction,
leeway
}]
.endpoint(save_query),
)
.branch(case![State::Search { bot_msg }].endpoint(search));
dialogue::enter::, State, _>()
.branch(message_handler)
.branch(callback_query_handler)
}
Осталось наполнить функции-эндпойнты и прилепить сбоку небольшую базу для хранения пользовательских преднастроек. После положительного фидбэка от соседей я решил не полагаться на надежность домашнего интернета и заказал самый дешевый VPS у провайдера, который мне не заплатил за эту статью, настроив Github Action на автодеплой на сервер при успешном билде релиза. Воспользоваться получившимся ботом можно здесь, а ещё можно посмотреть на исходники и сделать такой же, только круче и для Москвы или любого другого города, для которого доступен GTFS.
Существующие проблемы
Начальные остановки
Иногда бывает так, что интересующий вас маршрут появляется в фиде позже, чем вы рассчитывали. Например, если мониторить 220 автобус на остановке «Новоладожская улица», то фид появится примерно минут за 6 до прибытия, чего лично мне недостаточно. Это происходит из-за того, что данные появляются после того, как транспорт начнет движение от начальной остановки, соответственно, если вам надо сесть где-то близко к началу маршрута, уведомления вы можете и не получить. В такой ситуации получить уведомление вовремя практически нереально. Можно отслеживать координаты транспорта на карте и апдейтить позицию в сообщении, постоянно его редактируя. Сомнительная полезность, получаем тот же самый механизм, что и на картах, только менее удобный. Пока для таких ситуаций я обращаюсь к расписанию из Static GTFS — это лучше, чем ничего.
Для решения этой проблемы можно попробовать мониторить время прибытия обратного маршрута на конечную, добавлять к нему паузу между рейсами, высчитанную из Static GTFS и ориентировочное время в пути от начальной до требуемой остановки. В случае, если нужный фид не появился в течении заданного времени — пользоваться этими данными. Будет погрешность, но будет более-менее актуальный прогноз.
Возможно кто-то из команды транспорта @yandex поделится черным колдунством и расскажет, как они рассчитывают время прибытия, не имея фида.
UPD: судя по свежему посту @ovchinkin, GTFS они не используют, а полагаются на собственные механизмы.
Неполные и фрагментированные фиды
Запросил realtime-feed с координатами интересующих меня автобусов, получил вот такое. Не очень понятно, кто в этом виноват и что делать. Здесь могла бы быть полезная информация, но её нет.
trip: Some(
TripDescriptor {
trip_id: None,
route_id: Some(
"7634",
),
direction_id: None,
start_time: None,
start_date: None,
schedule_relationship: None,
},
),
Кроме того, не очень удобно вытаскивать кусочки фида при помощи трех разных запросов. Дайте мне его целиком, нужные данные я вытащу сам!
Мониторинг только одного маршрута
Поступил запрос на фичу от пользователей: отслеживать несколько маршрутов и остановок одновременно. Это действительно полезно, так как до дома иногда можно доехать несколькими равнозначными способами. В целом, я не вижу проблемы в реализации, единственное что нужно — немного свободного времени.
А какой функционал хотелось бы видеть вам? Пишите в комментариях и я попробую реализовать его. Пока сделал для себя вывод, что слишком сильно увлекаюсь и пропускаю уведомления от телеги. В дальнейших планах — взять какой-нибудь дикий китайский микроконтроллер с вайфаем из запасов, захардкодить в него нужные мне настройки и прикрутить к нему сирену со стробоскопом. Думаю, такое уведомление сложно будет не заметить.