Files
ridebus-board/README.md
Zakhar Timoshenko d04b364047 feat: add remote device management and pairing functionality
- 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.
2026-10-10 00:52:36 +03:00

100 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
```sh
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](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
Бесплатный Open-Meteo подходит для прототипа; [условия сервиса](https://open-meteo.com/en/terms) требуют коммерческую подписку для приложения с рекламой. Сервер уже поддерживает `OPEN_METEO_API_KEY` и автоматически выбирает коммерческий endpoint, ключ остаётся на сервере. Панель оператора планируется отдельно в `polotsk-transit-api`.
Production-сборка устанавливает Service Worker, сохраняющий оболочку, шрифты и иконки для перезагрузки без сети. Требуются **HTTPS или localhost** и хотя бы одна успешная загрузка; в режиме разработки Service Worker выключен. API-ответы не кешируются Service Worker. В приватном режиме браузера постоянное хранилище может быть недоступно.
## Проверки
```sh
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 и не предназначен для публикации.
```sh
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 загружает новую оболочку в фоне, активируя обновление при следующем закрытии/перезапуске табло.