- Implemented device pairing with a four-digit code and TTL for remote settings. - Introduced automatic checks for device status and configuration validation. - Developed UI components for device setup and management, including error handling and retry options. - Created a device bridge for secure communication between the device and the server. - Added tests for managed state validation and device pairing logic. - Established a local end-to-end fixture for testing device service and API interactions.
RideBus Board
Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Тёмная тема с фоном #000000 и светлая с #FFFFFF, локальный шрифт Manrope, контрастная типографика, отдельные акцентные палитры, иконки @vkontakte/icons и официальный SVG-логотип из RideBus PWA. По умолчанию — Полоцк, Швейная фабрика, stopId=1. Реальный API подтверждает эту остановку и 22 маршрута.
Запуск
Требуется Node.js 24+ (проверено с Node 24.14.1).
npm install
cp .env.example .env
# Добавьте RIDEBUS_API_KEY в .env.
npm run build
npm start
Откройте http://127.0.0.1:5180. Для разработки — npm run dev. В этом workspace ключ уже подключён локально из существующей конфигурации RideBus; .env исключён из Git и Docker. При отсутствии ключа первая загрузка включает явно обозначенный деморежим. Демонстрацию также можно открыть по /?demo=1; она никогда не подменяет реальные данные при сбое API.
Возможности
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами с явным счётчиком: каждые 7 секунд для телевизора и 10 секунд для уличного табло. В стандартном профиле на 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
- В шапке — название и направление конкретной площадки из API, часы и дата в
Europe/Minsk. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки. - Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
- Полный экран, пауза, ручная смена сцен. Клавиши:
S— настройки;F— полный экран;→— следующий экран;пробел— пауза. В настройкахEscзавершает изменение поля, затем закрывает меню без сохранения. - Настройки разделены на четыре раздела: «Остановка», «Оформление», «Экраны», «Реклама». Разделы, закрытие и «Сохранить» всегда видны. Для браузеров с курсором от пульта (например, Яндекс Браузер на Mi Box) доступны постоянные кнопки «Выше / Ниже»: наведите курсор и нажмите
OK. Они прокручивают содержимое выбранного раздела без колеса мыши и клавиатурных событий; у границы кнопка отключается. Черновик сохраняется при переходах между разделами. - Если браузер передаёт клавиши страницы, стрелки переводят фокус между полями и прокручивают содержимое раздела.
OK / Enterактивирует кнопку, переключатель или выбор цвета; для списка и числа включает изменение значения стрелками. ПовторныйOK / Enterвозвращает к навигации. Отключённые и скрытые поля пропускаются, выбранный элемент подсвечен.Home / Endдостигают крайних кнопок,PageUp / PageDownпрокручивают раздел.Tabвозвращает обычное клавиатурное управление; мышь и касание сохраняют стандартное поведение. - Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Высота панели сохраняется, а освободившуюся ширину занимает подпись «Обновлено» справа.
- Изменившиеся цифры часов, времени отправления, ожидания и номеров маршрутов прокручиваются вертикально. Названия рейсов при смене страниц плавно сдвигаются тем же движением. Неизменившиеся позиции чисел остаются на месте; увеличение идёт вверх, уменьшение — вниз. При
prefers-reduced-motionдвижение выключено. - Для чёрного фона доступны лайм, янтарь, голубой, мята, лаванда и белый; для белого — синий, зелёный, кармин, фиолетовый, бирюзовый и графит. Акцент и логотип меняются вместе. Контраст каждой палитры с её фоном не меньше 7:1. Тема и выбранные цвета сохраняются отдельно.
- Длинные номера, включая «16тк», автоматически вписываются с отступами. Центрирование и нижнее выравнивание чисел рассчитаны по границам глифов шрифта. Ожидание от часа отображается как «2 ч 1 мин»; единицы увеличены и выровнены снизу. Завтрашние отправления имеют одну подпись времени с пометкой «завтра».
Данные и работа без связи
Сервер запрашивает GET /api/v1/stops, /stops/:id, /eta/stop/:id, /alerts/active, передавая X-City-Id и серверный X-API-Key. Интерфейс опрашивает сервер каждые 20 секунд; одновременные запросы объединяются, ответ кешируется на 12 секунд. Ключ, ошибки и сырые ответы upstream не отправляются в браузер. Прокси разрешает только явно заданные GET-операции: /api/config, /api/stops, /api/board, /api/timetable, /api/weather.
Текущий ETA endpoint вычисляет время из расписания и исторических задержек, а не текущей GPS-позиции. Табло показывает время с пометкой «По расписанию»; историческая задержка не выдаётся за прогноз. Используются абсолютные дата и время, поэтому часы не запускают истёкшие рейсы повторно.
Полный кеш выбранной остановки загружается независимо через /api/timetable: все варианты /routes/:id/schedules, остановки маршрутов с временными смещениями и /holidays/year/:year. Сохраняются будние, субботние, воскресные и праздничные варианты, сроки действия и ограничения отдельных рейсов. Праздник без своего графика использует воскресный, как в RideBus API. Учитываются укороченные и обратные рейсы; прибытие после полуночи сохраняет настоящую дату и обслуживающий день. Обновление каждые 30 минут, ограниченная параллельная загрузка, одновременные запросы объединяются. Кеш публикуется целиком: сбой отдельного маршрута или календаря не заменяет предыдущую полную копию неполными данными.
При наличии полного кеша табло рассчитывает все прибытия сегодня и завтра локально. Поэтому оно продолжает выбирать рейсы после полуночи без подключения; завтрашние времена явно помечены «завтра». Кеш хранится в IndexedDB и резервно в localStorage отдельно по городу и площадке. Срок использования без обновления ограничен 7 сутками; по истечении этого срока табло ожидает свежую загрузку. Переводы рабочих дней могут отражаться только в календаре и расписании, которые предоставляет API. Демоданные не сохраняются вместо реального расписания. Если полный кеш ещё не получен, используется прежний снимок до трёх ближайших рейсов каждого маршрута, только для его исходного дня и не старше суток.
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
Для сопоставления отмен используется точный формат действующего Telegram-импортера API: дата, «не выполняются рейсы», название остановки и перечень времён; поддерживаются секции отдельных маршрутов. Сохраняются отправная остановка, время и день обслуживания, чтобы отмена правильно сопоставлялась на следующих остановках и после полуночи. Неоднозначные названия площадок или несколько совпавших рейсов не помечаются отменёнными автоматически. Устаревшие, будущие и истёкшие предупреждения не исключают отправления.
QR-код создаётся локально, без стороннего сервиса генерации, и открывает https://pwa.ridebus.by/?city=1&stop=1 для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной BOARD_PWA_URL. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
Погода запрашивается независимо через /api/weather у Open-Meteo по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
Бесплатный Open-Meteo подходит для прототипа; условия сервиса требуют коммерческую подписку для приложения с рекламой. Сервер уже поддерживает OPEN_METEO_API_KEY и автоматически выбирает коммерческий endpoint, ключ остаётся на сервере. Панель оператора планируется отдельно в polotsk-transit-api.
Production-сборка устанавливает Service Worker, сохраняющий оболочку, шрифты и иконки для перезагрузки без сети. Требуются HTTPS или localhost и хотя бы одна успешная загрузка; в режиме разработки Service Worker выключен. API-ответы не кешируются Service Worker. В приватном режиме браузера постоянное хранилище может быть недоступно.
Проверки
npm test
npm run build
npm run probe
Тесты проверяют календарное время, устаревшие снимки, идентичность города/остановки, источники ETA, многомаршрутные предупреждения, сбои отдельных источников, объединение запросов и HTTP-контракт. probe — проверка реального API без вывода ключа.
Развёртывание
Удалённое управление из polotsk-transit-api
Сначала примените миграцию backend/migrations/016_board_devices.sql в базе API и разверните обновлённые backend/frontend polotsk-transit-api. Для существующей базы выполняйте именно новую миграцию: старый общий скрипт проекта повторяет все SQL-файлы, включая создание уже существующих таблиц.
В .env табло включите BOARD_REMOTE_SETTINGS=true. RIDEBUS_API_BASE_URL указывает на обновлённый сервер API, RIDEBUS_API_KEY остаётся только на сервере Board. Необязательный BOARD_ADMIN_URL — полный HTTPS-адрес раздела устройств вашей админки. Compose передаёт все эти параметры в контейнер. После изменения переменных пересоберите/пересоздайте контейнер.
При первом запуске каждый браузер показывает собственный четырёхзначный код, действующий 5 минут. Администратор открывает «Устройства», добавляет табло по коду, выбирает название, город (через переключатель сети), конкретную площадку и настройки. Код одноразовый; после привязки табло запускается автоматически. Смена остановки, маршрута, темы, палитры, профиля, числа строк, экранов, информационных блоков и рекламы применяется в течение следующего опроса, без обновления страницы. Деморежим удалённой конфигурации выключен.
Во время привязки опрос каждые 3 секунды, после — каждые 15. Сигнал работы каждые 30 секунд и отдельное подтверждение после применения новой версии. Админка различает «Ожидают применения» и «Настройки применены»; после 90 секунд без связи устройство считается офлайн. Конкурирующие изменения обнаруживаются по версии, старое сохранение не перезаписывает новое.
Идентичность устройства — отдельный профиль браузера на отдельном origin. Вкладки одного браузера с одним адресом относятся к одному устройству. Токен хранится в HttpOnly cookie и не передаётся в JavaScript или URL; на API сохраняется только SHA-256 хеш. Для сервера с HTTPS нужен Secure cookie: передавайте X-Forwarded-Proto: https через reverse proxy либо задайте BOARD_COOKIE_SECURE=true. Очищенные cookies, смена адреса или профиля требуют новой привязки. Не запускайте табло в приватном режиме.
При потере управления табло использует последнюю проверенную конфигурацию и существующий кеш расписания. Локальная конфигурация автономного режима хранится отдельно. После привязки меню на телевизоре показывает состояние управления, имя устройства и версию; параметры меняются в админке. «Отвязать» отзывает токен; экран прекращает показ при следующей связи с API. Для новой привязки нажмите «Подключить заново» на табло. При отключённом BOARD_REMOTE_SETTINGS сохраняется прежняя локальная настройка.
Для локальной совместной проверки после сборки обоих проектов: node test/fixtures/device-flow-server.mjs. Это явно обозначенный тестовый стенд с настоящими устройственными обработчиками и UI, но с памятью вместо PostgreSQL и тестовым транспортом. Админка — http://127.0.0.1:5190/devices, табло — http://127.0.0.1:5191; тестовый вход board-fixture / fixture-only. Этот файл не входит в Docker runtime и не предназначен для публикации.
docker compose up --build -d
Контейнер доступен на 127.0.0.1:5180. Для удалённого плеера поставьте HTTPS reverse proxy перед этим портом. API-ключ передаётся только через environment; не включайте его в URL или frontend-переменные VITE_*. /healthz проверяет работу процесса, а не доступность RideBus API.
На Linux-плеере откройте страницу Chromium в kiosk mode и настройте автозапуск графической сессии. Пример службы — deploy/ridebus-kiosk.service; пути Chromium и переменные дисплея зависят от ОС. Restart=always восстанавливает процесс браузера. Docker восстанавливает сервер после перезагрузки. Настройку и испытание на физическом плеере нужно выполнить отдельно: служба не обнаруживает зависший renderer, а удалённый мониторинг устройств в MVP отсутствует.
Изменения кода выкладывайте целой сборкой; Service Worker загружает новую оболочку в фоне, активируя обновление при следующем закрытии/перезапуске табло.