Долгое время я жил в районе, где общественный транспорт был скорее проблемой, чем благом (привет, Кудрово!). Приходилось в любую погоду ходить пешком до метро три километра, в мороз, слякоть и зной. Спустя некоторое время я переехал и появилась возможность доезжать от офиса до дома на прямом автобусе. Это звучит очень клево, но единственный маршрут ходит с большим интервалом и как попало. Приходилось или подолгу стоять на остановке, или нервно поглядывать на Яндекс.Карты и отслеживать перемещения ближайшего автобуса последние полчаса перед выходом, что сводило на нет остатки продуктивности и неслабо раздражало.

А после того, как я узнал, что на карте отображаются далеко не весь транспорт на линии, терпение лопнуло.

Постановка задачи и поиск решения

У меня есть потребность не мокнуть под дождём на остановке, потребность в ненапряжном и общественно‑полезном НЕ ЭМБЕДДЭД пет‑проекте. Я хочу жать одну кнопку и получать уведомление, когда мне стоит выключать паяльник и сваливать. Всё. Первым делом я стал искать готовое решение — не может же быть так, что эта очевидная проблема требует своего велосипеда. К большому удивлению, ничего подходящего не нашлось. Логичным показалось поискать какое‑нибудь публичное 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,
    },
),

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

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

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


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

Комментарии (2)