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.txtstops.txttrips.txtstop_times.txt) и безжалостно выпотрошить нужную инфу. В принципе, если интересны только актуальные данные, то достаточно только routes.txt и stops.txt. Я же хотел подстраховаться на случай очередного падения портала КТ (они случаются с завидной регулярностью) и оставить возможность опираться на расписание. Чуть ниже расскажу, почему это оказалось неплохим решением.

С динамическими данными все достаточно тривиально. Посылается запрос с ID интересующей нас остановки и забирается фид. После декодирования он превращается в структуру, содержащую данные о всех рейсах, прибывающих на эту остановку в ближайшее время. Фильтруем по интересующему маршруту и направлению движения, получаем прогнозируемое время прибытия. Частоту обращения за этим фидом стоит подбирать пропорционально непредсказуемости дорожной ситуации в вашем городе. Мне показался оптимальным период обновления раз в 5 секунд. Будьте внимательны — то, что нужный маршрут был в прошлом фиде, не гарантирует его наличия в следующем! Они пропадают и появляются по не поддающейся анализу закономерности.

Логика диалогов

Беглый просмотр crates.io показал, что самый популярный и поддерживаемый фреймворк на данный момент — Teloxide. Потыкался в примеры и отрисовал примерную диаграмму взаимодействия с пользователем.

То, как это увидел Mermaid

То, как это увидел 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<Box<dyn Error + Send + Sync + 'static>> {     use dptree::case;      let command_handler =         teloxide::filter_command::<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::<Update, ErasedStorage<State>, 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,     }, ), 

Кроме того, не очень удобно вытаскивать кусочки фида при помощи трех разных запросов. Дайте мне его целиком, нужные данные я вытащу сам!

Мониторинг только одного маршрута

Поступил запрос на фичу от пользователей: отслеживать несколько маршрутов и остановок одновременно. Это действительно полезно, так как до дома иногда можно доехать несколькими равнозначными способами. В целом, я не вижу проблемы в реализации, единственное что нужно — немного свободного времени.


А какой функционал хотелось бы видеть вам? Пишите в комментариях и я попробую реализовать его. Пока сделал для себя вывод, что слишком сильно увлекаюсь и пропускаю уведомления от телеги. В дальнейших планах — взять какой‑нибудь дикий китайский микроконтроллер с вайфаем из запасов, захардкодить в него нужные мне настройки и прикрутить к нему сирену со стробоскопом. Думаю, такое уведомление сложно будет не заметить.


ссылка на оригинал статьи https://habr.com/ru/articles/733454/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *