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.
@@ -11,3 +11,9 @@ BOARD_PWA_URL=https://pwa.ridebus.by/
|
|||||||
OPEN_METEO_API_KEY=
|
OPEN_METEO_API_KEY=
|
||||||
PORT=5180
|
PORT=5180
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
|
# Managed displays: pair each browser in the admin's Devices section.
|
||||||
|
BOARD_REMOTE_SETTINGS=false
|
||||||
|
# Optional URL shown on the pairing screen.
|
||||||
|
BOARD_ADMIN_URL=
|
||||||
|
# Set true behind an HTTPS reverse proxy if it does not send X-Forwarded-Proto.
|
||||||
|
BOARD_COOKIE_SECURE=false
|
||||||
|
|||||||
25
README.md
@@ -18,11 +18,14 @@ npm start
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами с явным счётчиком: каждые 7 секунд для телевизора и 10 секунд для уличного табло. В стандартном профиле на 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
||||||
|
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
|
||||||
|
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
|
||||||
|
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
|
||||||
|
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
|
||||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||||
- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
|
- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
|
||||||
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
|
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
|
||||||
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
|
|
||||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
||||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||||
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
||||||
@@ -47,6 +50,8 @@ npm start
|
|||||||
|
|
||||||
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
||||||
|
|
||||||
|
Для сопоставления отмен используется точный формат действующего Telegram-импортера API: дата, «не выполняются рейсы», название остановки и перечень времён; поддерживаются секции отдельных маршрутов. Сохраняются отправная остановка, время и день обслуживания, чтобы отмена правильно сопоставлялась на следующих остановках и после полуночи. Неоднозначные названия площадок или несколько совпавших рейсов не помечаются отменёнными автоматически. Устаревшие, будущие и истёкшие предупреждения не исключают отправления.
|
||||||
|
|
||||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
|
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.
|
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||||
@@ -67,6 +72,22 @@ npm run probe
|
|||||||
|
|
||||||
## Развёртывание
|
## Развёртывание
|
||||||
|
|
||||||
|
### Удалённое управление из 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
|
```sh
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 43 KiB |
BIN
artifacts/device-management-offline.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
artifacts/device-pairing-screen.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/device-remote-light.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
artifacts/device-repairing.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/devices-admin-applied.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
26
artifacts/verification-departure-features.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Проверка четырёх функций табло — 9 октября 2026
|
||||||
|
|
||||||
|
Добавлены отмены в строках отправлений, следующий рейс того же направления, крупное время при ожидании от 90 минут и профили читаемости.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
- `npm test`: 64 теста прошли. Проверены точные и общие уведомления, несколько маршрутов, неоднозначные площадки/рейсы, устаревшие предупреждения, прибытия после полуночи, следующий рейс и сохранение настроек.
|
||||||
|
- `npm run build`: финальная TypeScript/Vite-сборка. Временная визуальная сборка исключена из итогового `dist`.
|
||||||
|
- `git diff --check`: без ошибок форматирования.
|
||||||
|
- Импорт серверных модулей из изолированной структуры runtime прошёл. В Dockerfile добавлен общий модуль обработки отмен. Docker-контейнер не запускался.
|
||||||
|
|
||||||
|
## Браузер
|
||||||
|
|
||||||
|
Основная страница проверена с настоящими данными остановки «Швейная фабрика»: следующий рейс того же направления отображается, завтрашнее время не дублируется. После перезапуска локального сервера API вернул расписание 22 маршрутов с метаданными отправной остановки и дня обслуживания. Временный сбой upstream вернул 502; повторный запрос завершился успешно.
|
||||||
|
|
||||||
|
Отмена и большие интервалы проверены на явно обозначенных тестовых данных с настоящим компонентом `DeparturesScreen`; исходник сценариев находится в `test/fixtures/board-features.tsx`. Тестовые отмены не выдавались за действующие сообщения API.
|
||||||
|
|
||||||
|
- **1079×591, 6 строк:** страницы по три строки, высота строк 73–74 px, промежуток между временем и ожиданием около 24 px, содержимое не переполняет список.
|
||||||
|
- **855×723, 4 строки:** четыре строки одновременно, высота 76–77 px, без пересечений.
|
||||||
|
- **879×593:** отменённый рейс пропускается при выборе ближайшего; ночной сценарий показывает «Завтра в 05:40» и следующий рейс «06:20 завтра».
|
||||||
|
- **390×667:** длинное ожидание, переход к отсчёту за 10 минут, длинные названия, все рейсы отменены; в профиле «улица / далеко» одна крупная строка на странице.
|
||||||
|
- **1280×900:** тёмная тема с отменами и светлая тема с крупным временем «Сегодня в 14:05».
|
||||||
|
- Автоматическая смена страниц уличного профиля наблюдалась через 10 секунд; пауза останавливает смену.
|
||||||
|
- На реальной странице профиль «улица / далеко» и выключенные новые функции сохранились после перезагрузки. В меню 879×593 новые поля доступны через постоянные кнопки «Ниже». Затем восстановлены «телевизор / близко», оба новых переключателя включены, удержание ближайшего рейса включено, 4 строки, тёмная лаймовая тема.
|
||||||
|
|
||||||
|
Снимки `departure-features-*.jpg` находятся рядом с этим файлом. Итоговый пример отмены — `departure-features-cancellation.jpg`. Физический Xiaomi Mi Box 4 с Яндекс Браузером не проверялся.
|
||||||
37
artifacts/verification-device-management.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Удалённое управление табло — 10 октября 2026
|
||||||
|
|
||||||
|
Изменения сделаны совместно в `/Users/xtimms/Projects/ridebus-board` и `/Users/xtimms/Projects/polotsk-transit-api`.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
- При включённом `BOARD_REMOTE_SETTINGS` новое устройство показывает четырёхзначный код с TTL 5 минут; привязка выполняется администратором в разделе «Устройства» выбранного города.
|
||||||
|
- Индивидуальный токен в HttpOnly cookie, на API только хеш. Код занимается транзакционно, параметры остановки/маршрута проверяются по городу. Отдельные права устройства и администратора.
|
||||||
|
- Настройки с версиями и оптимистическим сохранением. Подтверждение применения от телевизора; состояние связи с порогом 90 секунд.
|
||||||
|
- Удалённые настройки всех существующих параметров табло, ограниченное локальное меню, кеш конфигурации, отказ от показа после получения отзыва доступа, повторное подключение по новому коду.
|
||||||
|
- Автономный режим сохранён; параметр управления по умолчанию выключен до развёртывания обновлённого API.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- Board: 66 тестов прошли; `npm run build` (TypeScript + Vite) успешен.
|
||||||
|
- API: полный набор — 96 прошли, 15 пропущены из-за отсутствия отдельной PostgreSQL БД. Новые целевые проверки — 7 прошли, 1 PostgreSQL-тест пропущен. Повторная целевая проверка после уточнения лимитов тоже успешна.
|
||||||
|
- Admin: Vite production build успешен; существующие предупреждения о Browserslist и размере общего bundle.
|
||||||
|
- `git diff --check` без ошибок в обоих проектах.
|
||||||
|
- Сервер Board импортирован из изолированного дерева с тем же набором общих файлов, который копируется в Docker runtime. Docker-контейнер не запускался: локальный daemon недоступен.
|
||||||
|
|
||||||
|
## Совместный браузерный стенд
|
||||||
|
|
||||||
|
Использованы настоящие обработчики API устройств, сервис, мост Board и UI обоих проектов. Репозиторий устройств в памяти и транспортные данные явно тестовые; это не доказательство работы с производственной БД. Файл стенда: `test/fixtures/device-flow-server.mjs`, тестовый сервер доступен только на loopback и остановлен после проверки.
|
||||||
|
|
||||||
|
Проверены через интерфейс:
|
||||||
|
|
||||||
|
1. Вход тестового администратора и раздел «Устройства».
|
||||||
|
2. Ввод кода, автоматический переход табло к расписанию, статус «В сети / Настройки применены · v1».
|
||||||
|
3. Изменение остановки на «Автовокзал», светлая зелёная тема, уличный профиль и дальний просмотр; автоматическое применение без перезагрузки, затем «Настройки применены · v2» в админке.
|
||||||
|
4. Перезагрузка сохраняет привязку; локальное меню показывает «Управляется из админки» и версию 2.
|
||||||
|
5. Отвязка в админке прекращает показ; кнопка на табло получает новый код.
|
||||||
|
6. Недоступность серверной части управления и перезагрузка страницы: восстановлены имя остановки и параметры из кеша, показаны сообщения о сохранённом расписании и отсутствии связи с управлением.
|
||||||
|
7. Восстановление связи: устройство снова «В сети», сохранённая конфигурация подтверждена.
|
||||||
|
|
||||||
|
Снимки: `device-pairing-screen.jpg`, `devices-admin-applied.jpg`, `device-remote-light.jpg`, `device-repairing.jpg`, `device-management-offline.jpg`.
|
||||||
|
|
||||||
|
Миграция 016 подготовлена, но к рабочей базе не применялась. Обновлённые API и админка на сервер не выкладывались. Физический Xiaomi Mi Box 4 не проверялся. Исходный локальный Board на 5180 перезапущен с новым серверным кодом, его автономные настройки сохранены; тестовые вкладки закрыты.
|
||||||
@@ -12,5 +12,8 @@ services:
|
|||||||
BOARD_STOP_ID: ${BOARD_STOP_ID:-1}
|
BOARD_STOP_ID: ${BOARD_STOP_ID:-1}
|
||||||
BOARD_PWA_URL: ${BOARD_PWA_URL:-https://pwa.ridebus.by/}
|
BOARD_PWA_URL: ${BOARD_PWA_URL:-https://pwa.ridebus.by/}
|
||||||
OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-}
|
OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-}
|
||||||
|
BOARD_REMOTE_SETTINGS: ${BOARD_REMOTE_SETTINGS:-false}
|
||||||
|
BOARD_ADMIN_URL: ${BOARD_ADMIN_URL:-}
|
||||||
|
BOARD_COOKIE_SECURE: ${BOARD_COOKIE_SECURE:-false}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${BOARD_PORT:-5180}:5180"
|
- "127.0.0.1:${BOARD_PORT:-5180}:5180"
|
||||||
|
|||||||
59
server/device.mjs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ApiError } from './board.mjs';
|
||||||
|
|
||||||
|
const COOKIE = 'ridebus_board_device';
|
||||||
|
const tokenFrom = req => { const value = req.headers.cookie?.split(';').map(item => item.trim()).find(item => item.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1); return /^[A-Za-z0-9_-]{43}$/.test(value || '') ? value : ''; };
|
||||||
|
export function createDeviceBridge({ enabled = process.env.BOARD_REMOTE_SETTINGS === 'true', baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', adminUrl = process.env.BOARD_ADMIN_URL || '', fetchData = fetch } = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
async function upstream(path, token, body) {
|
||||||
|
let response;
|
||||||
|
try { response = await fetchData(new URL(`api/v1/board-devices/${path}`, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`), {
|
||||||
|
method: body === undefined ? 'GET' : 'POST', headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { 'X-Board-Token': token } : {}) },
|
||||||
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(10000),
|
||||||
|
}); } catch { throw new ApiError(502, 'devices_unavailable'); }
|
||||||
|
if (!response.ok) {
|
||||||
|
const codes = { 401: 'device_unpaired', 410: 'device_revoked', 429: 'device_rate_limit' };
|
||||||
|
throw new ApiError(codes[response.status] ? response.status : 502, codes[response.status] || 'devices_unavailable');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
async function status(req, force = false) {
|
||||||
|
const token = tokenFrom(req);
|
||||||
|
if (!token) return { state: 'unpaired' };
|
||||||
|
const cached = cache.get(token);
|
||||||
|
if (!force && cached?.until > Date.now()) return cached.value;
|
||||||
|
try {
|
||||||
|
const value = await upstream('self', token);
|
||||||
|
if (cache.size >= 1000) cache.delete(cache.keys().next().value);
|
||||||
|
cache.set(token, { value, until: Date.now() + 15000 }); return value;
|
||||||
|
} catch (error) { cache.delete(token); if (error.code === 'device_unpaired') return { state: 'unpaired' }; throw error; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
async status(req, config) {
|
||||||
|
if (!enabled) return { enabled: false };
|
||||||
|
const value = await status(req, true);
|
||||||
|
return { ...value, enabled: true, adminUrl, ...(value.state === 'paired' ? { config: { ...config, cityId: value.device.cityId, cityName: value.device.cityName, stopName: value.device.stopName, defaultStopId: value.device.settings.stopId } } : {}) };
|
||||||
|
},
|
||||||
|
async start(req, res) {
|
||||||
|
if (!enabled) throw new ApiError(404, 'not_found');
|
||||||
|
const previous = await status(req, true).catch(error => { if (error.code === 'device_revoked') return { state: 'revoked' }; throw error; });
|
||||||
|
if (['paired', 'pending'].includes(previous.state)) return { ...previous, enabled: true, adminUrl };
|
||||||
|
const value = await upstream('pairings', '', req.body || {});
|
||||||
|
if (!/^[A-Za-z0-9_-]{43}$/.test(value.token || '')) throw new ApiError(502, 'devices_unavailable');
|
||||||
|
const oldToken = tokenFrom(req); if (oldToken) cache.delete(oldToken);
|
||||||
|
res.cookie(COOKIE, value.token, { httpOnly: true, sameSite: 'strict', secure: req.secure || req.get('X-Forwarded-Proto') === 'https' || process.env.BOARD_COOKIE_SECURE === 'true', path: '/api', maxAge: 365 * 86400000 });
|
||||||
|
const { token: _token, ...publicValue } = value;
|
||||||
|
return { ...publicValue, enabled: true, adminUrl };
|
||||||
|
},
|
||||||
|
async heartbeat(req) {
|
||||||
|
if (!enabled || !tokenFrom(req)) throw new ApiError(401, 'device_unpaired');
|
||||||
|
return upstream('self/heartbeat', tokenFrom(req), req.body || {});
|
||||||
|
},
|
||||||
|
async context(req) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
const value = await status(req);
|
||||||
|
if (value.state !== 'paired') throw new ApiError(401, 'device_unpaired');
|
||||||
|
return value.device;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,37 +4,58 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { ApiError, createBoardClient } from './board.mjs';
|
import { ApiError, createBoardClient } from './board.mjs';
|
||||||
import { createWeatherClient } from './weather.mjs';
|
import { createWeatherClient } from './weather.mjs';
|
||||||
|
import { createDeviceBridge } from './device.mjs';
|
||||||
|
|
||||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
|
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient(), deviceBridge = createDeviceBridge(), clientFactory = createBoardClient) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
|
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
|
||||||
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
app.use('/api/device', express.json({ limit: '8kb' }), (req, res, next) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
let origin; try { origin = req.get('Origin') ? new URL(req.get('Origin')) : null; } catch { return res.status(403).json({ error: 'invalid_origin' }); }
|
||||||
|
if (req.get('Sec-Fetch-Site') === 'cross-site' || origin && origin.host !== req.get('Host') || !req.is('application/json')) return res.status(403).json({ error: 'invalid_origin' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.get('/api/device/status', async (req, res) => res.json(await deviceBridge.status(req, client.config())));
|
||||||
|
app.post('/api/device/pair', async (req, res) => res.json(await deviceBridge.start(req, res)));
|
||||||
|
app.post('/api/device/heartbeat', async (req, res) => res.json(await deviceBridge.heartbeat(req)));
|
||||||
|
const clients = new Map();
|
||||||
app.use('/api', (req, res, next) => {
|
app.use('/api', (req, res, next) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
|
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
app.use('/api', async (req, _res, next) => {
|
||||||
|
if (!deviceBridge.enabled || req.path === '/config') { req.boardClient = client; return next(); }
|
||||||
|
const device = await deviceBridge.context(req);
|
||||||
|
if (!clients.has(device.cityId)) clients.set(device.cityId, clientFactory({ cityId: device.cityId, cityName: device.cityName, stopName: device.stopName, defaultStopId: device.settings.stopId }));
|
||||||
|
req.boardClient = clients.get(device.cityId);
|
||||||
|
if (req.query.stopId && req.query.stopId !== String(device.settings.stopId)) throw new ApiError(409, 'settings_changed');
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.get('/api/config', (_req, res) => res.json(client.config()));
|
app.get('/api/config', (_req, res) => res.json(client.config()));
|
||||||
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
|
app.get('/api/stops', async (req, res) => { res.json(await req.boardClient.stops()); });
|
||||||
app.get('/api/board', async (req, res) => {
|
app.get('/api/board', async (req, res) => {
|
||||||
const raw = req.query.stopId;
|
const raw = req.query.stopId;
|
||||||
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
|
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
|
||||||
const data = await client.board(Number(raw));
|
const data = await req.boardClient.board(Number(raw));
|
||||||
res.json({ ...data, serverTime: new Date().toISOString() });
|
res.json({ ...data, serverTime: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
const stopId = req => {
|
const stopId = req => {
|
||||||
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
|
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
|
||||||
return Number(req.query.stopId);
|
return Number(req.query.stopId);
|
||||||
};
|
};
|
||||||
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
|
app.get('/api/timetable', async (req, res) => res.json(await req.boardClient.timetable(stopId(req))));
|
||||||
app.get('/api/weather', async (req, res) => {
|
app.get('/api/weather', async (req, res) => {
|
||||||
const id = stopId(req);
|
const id = stopId(req);
|
||||||
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
|
res.json(await weatherClient.weather(req.boardClient.config().cityId, id, await req.boardClient.coordinates(id)));
|
||||||
});
|
});
|
||||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
|
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||||
// Never return upstream bodies, stack traces or API credentials.
|
// Never return upstream bodies, stack traces or API credentials.
|
||||||
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
app.use((error, _req, res, _next) => { res.status(error.type === 'entity.parse.failed' ? 400 : error.type === 'entity.too.large' ? 413 : error instanceof ApiError ? error.status : 502).json({ error: ['entity.parse.failed', 'entity.too.large'].includes(error.type) ? 'invalid_request' : error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
src/App.tsx
@@ -21,6 +21,9 @@ import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDeparture
|
|||||||
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||||
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
||||||
import { SettingsPanel } from './SettingsPanel';
|
import { SettingsPanel } from './SettingsPanel';
|
||||||
|
import { useDeviceControl } from './useDeviceControl';
|
||||||
|
import type { ManagedState } from './useDeviceControl';
|
||||||
|
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
|
||||||
|
|
||||||
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
@@ -136,12 +139,21 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const remote = useDeviceControl();
|
||||||
|
if (remote.control.state === 'standalone') return <BoardApplication />;
|
||||||
|
if (remote.managed) return <BoardApplication key={`${remote.managed.device.id}:${remote.managed.config.cityId}`} management={{ state: remote.managed, failed: remote.failed, onApplied: remote.onApplied }} />;
|
||||||
|
return <DeviceSetup control={remote.control} failed={remote.failed} onRetry={remote.retry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardApplication({ management }: { management?: { state: ManagedState; failed: boolean; onApplied: (revision: number) => void } }) {
|
||||||
const [settings, setSettings] = useState<Settings>(() => {
|
const [settings, setSettings] = useState<Settings>(() => {
|
||||||
|
if (management) return management.state.device.settings;
|
||||||
const saved = readSettings();
|
const saved = readSettings();
|
||||||
if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true;
|
if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true;
|
||||||
return saved;
|
return saved;
|
||||||
});
|
});
|
||||||
const [config, setConfig] = useState(fallbackConfig);
|
const [config, setConfig] = useState(management?.state.config || fallbackConfig);
|
||||||
|
const acknowledged = useRef(0);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const controlsVisible = useIdleControls(settingsOpen);
|
const controlsVisible = useIdleControls(settingsOpen);
|
||||||
const [scene, setScene] = useState<Scene>('departures');
|
const [scene, setScene] = useState<Scene>('departures');
|
||||||
@@ -201,6 +213,7 @@ export default function App() {
|
|||||||
catch { showNotice('Полный экран недоступен в этом браузере'); }
|
catch { showNotice('Полный экран недоступен в этом браузере'); }
|
||||||
}, [showNotice]);
|
}, [showNotice]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (management) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => {
|
fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => {
|
||||||
setConfig(value);
|
setConfig(value);
|
||||||
@@ -208,6 +221,17 @@ export default function App() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!management) return;
|
||||||
|
setSettings(management.state.device.settings); setConfig(management.state.config);
|
||||||
|
setSettingsOpen(false); setScene('departures'); setInfoPage(0); setStartedAt(Date.now());
|
||||||
|
}, [management?.state.device.revision]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (management && settings === management.state.device.settings && acknowledged.current !== management.state.device.revision) {
|
||||||
|
acknowledged.current = management.state.device.revision;
|
||||||
|
management.onApplied(management.state.device.revision);
|
||||||
|
}
|
||||||
|
}, [management, settings]);
|
||||||
useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]);
|
useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
||||||
@@ -241,6 +265,7 @@ export default function App() {
|
|||||||
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
|
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
|
||||||
</header>
|
</header>
|
||||||
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
|
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
|
||||||
|
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
|
||||||
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
||||||
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
|
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
|
||||||
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings}
|
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings}
|
||||||
@@ -254,6 +279,6 @@ export default function App() {
|
|||||||
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
|
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
|
||||||
</footer>
|
</footer>
|
||||||
{notice && <div className="toast" role="status">{notice}</div>}
|
{notice && <div className="toast" role="status">{notice}</div>}
|
||||||
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
|
{settingsOpen && (management ? <ManagedPanel state={management.state} failed={management.failed} onClose={() => setSettingsOpen(false)} onFullscreen={() => void toggleFullscreen()} /> : <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />)}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/DeviceSetup.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import logo from './assets/ridebus-logo.svg?raw';
|
||||||
|
import { NumericText } from './NumericText';
|
||||||
|
import { Fullscreen, Refresh, Shield } from './Icons';
|
||||||
|
import type { ManagedState } from './useDeviceControl';
|
||||||
|
export function DeviceSetup({ control, failed, onRetry }: { control: { state: string; code?: string; expiresAt?: string; adminUrl?: string }; failed: boolean; onRetry: () => void }) {
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
useEffect(() => { document.title = 'Подключение · RideBus Board'; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, []);
|
||||||
|
const remaining = Math.max(0, Math.min(300, Math.ceil((Date.parse(control.expiresAt || '') - now) / 1000)));
|
||||||
|
const pending = control.state === 'pending' && remaining > 0;
|
||||||
|
return <main className="device-setup" aria-label="Подключение табло"><div className="device-setup-brand"><span dangerouslySetInnerHTML={{ __html: logo }} />ridebus</div><div className="device-setup-card"><span className="eyebrow">RIDEBUS BOARD</span><h1>{control.state === 'revoked' ? 'Устройство отвязано' : 'Подключите табло'}</h1>
|
||||||
|
<p>{pending ? 'Введите этот код в админке: Устройства → Добавить устройство' : control.state === 'revoked' ? 'Для повторной настройки подключите устройство по новому коду' : control.state === 'storage-error' ? 'Разрешите браузеру сохранять cookies и данные сайта' : control.state === 'loading' ? 'Получаем код подключения…' : 'Для подключения нужен доступ к серверу'}</p>
|
||||||
|
{pending && <><div className="device-pair-code" aria-label={`Код подключения ${control.code}`}><NumericText value={control.code || ''} /></div><div className="device-pair-expiry">Код действует ещё <NumericText value={`${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`} /></div></>}
|
||||||
|
{control.adminUrl && <a href={control.adminUrl} target="_blank" rel="noreferrer">Открыть админку</a>}
|
||||||
|
{failed && <div className="device-setup-status" role="status">{control.state === 'storage-error' ? 'Не удалось сохранить привязку устройства' : 'Нет связи с сервером. Повторяем подключение'}</div>}
|
||||||
|
{!pending && control.state !== 'loading' && <button onClick={onRetry}><Refresh />{control.state === 'revoked' ? 'Подключить заново' : 'Повторить'}</button>}
|
||||||
|
</div><button className="device-setup-fullscreen" onClick={() => { void document.documentElement.requestFullscreen().catch(() => {}); }}><Fullscreen />На весь экран</button></main>;
|
||||||
|
}
|
||||||
|
export function ManagedPanel({ state, failed, onClose, onFullscreen }: { state: ManagedState; failed: boolean; onClose: () => void; onFullscreen: () => void }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
useEffect(() => { dialog.current?.showModal(); }, []);
|
||||||
|
return <dialog ref={dialog} className="managed-panel" aria-labelledby="managed-title" onCancel={event => { event.preventDefault(); onClose(); }}><Shield size={36} /><h2 id="managed-title">Управляется из админки</h2><strong>{state.device.name}</strong><p>Изменяйте остановку, оформление и экраны в разделе «Устройства».</p><dl><dt>Настройки</dt><dd>Версия {state.device.revision}</dd><dt>Соединение</dt><dd>{failed ? 'Нет связи · сохранённые настройки' : 'Подключено'}</dd></dl><div><button onClick={onFullscreen}><Fullscreen />Полный экран</button><button onClick={onClose}>Закрыть</button></div></dialog>;
|
||||||
|
}
|
||||||
12
src/lib/device.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
|
||||||
|
export const MANAGED_KEY = 'ridebus-board-managed-v1';
|
||||||
|
export function isManagedState(value) {
|
||||||
|
const device = value?.device, s = device?.settings, config = value?.config;
|
||||||
|
if (value?.state !== 'paired' || !device || !s || !config || typeof device.id !== 'string' || typeof device.name !== 'string' || !Number.isInteger(device.revision) || device.revision < 1 || !Number.isInteger(device.cityId) || device.cityId < 1 || config.cityId !== device.cityId || config.defaultStopId !== s.stopId) return false;
|
||||||
|
if (typeof config.configured !== 'boolean' || !Number.isInteger(config.refreshSeconds) || config.refreshSeconds < 5 || config.refreshSeconds > 120 || config.timeZone !== 'Europe/Minsk') return false;
|
||||||
|
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) if (!Number.isInteger(s[key]) || s[key] < min || s[key] > max) return false;
|
||||||
|
if (s.demo !== false || !['dark', 'light'].includes(s.theme) || !['tv', 'street'].includes(s.displayProfile) || !['near', 'medium', 'far'].includes(s.viewingDistance)) return false;
|
||||||
|
if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false;
|
||||||
|
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
|
||||||
|
return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string';
|
||||||
|
}
|
||||||
@@ -515,3 +515,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
|||||||
.destination-old { visibility: hidden; }
|
.destination-old { visibility: hidden; }
|
||||||
.destination-current { transform: none !important; }
|
.destination-current { transform: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Device onboarding keeps the same black/lime typography as the board. */
|
||||||
|
.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}}
|
||||||
|
|||||||
64
src/useDeviceControl.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Config, Settings } from './types';
|
||||||
|
import { isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
||||||
|
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
||||||
|
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
||||||
|
function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } }
|
||||||
|
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
||||||
|
export function useDeviceControl() {
|
||||||
|
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
||||||
|
const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0);
|
||||||
|
const applied = useRef(0), current = useRef(managed);
|
||||||
|
current.current = managed;
|
||||||
|
const onApplied = useCallback((revision: number) => {
|
||||||
|
applied.current = revision;
|
||||||
|
try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ }
|
||||||
|
void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false, timer: ReturnType<typeof setTimeout>, controller: AbortController | undefined, lastHeartbeat = 0;
|
||||||
|
let previousState = '', halted = false, running = false, firstRequest = true;
|
||||||
|
async function call(path: string, body?: object) {
|
||||||
|
controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) });
|
||||||
|
const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value;
|
||||||
|
} finally { clearTimeout(timeout); }
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
if (stopped || halted || running) return;
|
||||||
|
running = true;
|
||||||
|
let delay = 15000;
|
||||||
|
try {
|
||||||
|
let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined);
|
||||||
|
if (stopped) return;
|
||||||
|
firstRequest = false;
|
||||||
|
if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; }
|
||||||
|
if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable');
|
||||||
|
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
||||||
|
if (value.state === 'paired' && !value.config) value = await call('status');
|
||||||
|
if (value.state === 'paired') {
|
||||||
|
if (!isManagedState(value)) throw new Error('invalid_settings');
|
||||||
|
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
||||||
|
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
||||||
|
} else if (value.state === 'pending') {
|
||||||
|
if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable');
|
||||||
|
delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); }
|
||||||
|
} else throw new Error('devices_unavailable');
|
||||||
|
previousState = value.state;
|
||||||
|
} catch (error) {
|
||||||
|
if (!stopped) {
|
||||||
|
const code = error instanceof Error ? error.message : 'devices_unavailable';
|
||||||
|
setFailed(true);
|
||||||
|
if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; }
|
||||||
|
if (code === 'storage_unavailable') halted = true;
|
||||||
|
if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' }));
|
||||||
|
delay = 30000;
|
||||||
|
}
|
||||||
|
} finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); }
|
||||||
|
}
|
||||||
|
void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online);
|
||||||
|
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); };
|
||||||
|
}, [retry]);
|
||||||
|
return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) };
|
||||||
|
}
|
||||||
49
test/device.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { isManagedState } from '../src/lib/device.mjs';
|
||||||
|
import { createDeviceBridge } from '../server/device.mjs';
|
||||||
|
import { createApp } from '../server/index.mjs';
|
||||||
|
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
|
||||||
|
// Settings used by the actual board; no external project is required for these tests.
|
||||||
|
const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1];
|
||||||
|
const defaults = Function(`return (${body})`)();
|
||||||
|
const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } };
|
||||||
|
test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => {
|
||||||
|
assert.equal(isManagedState(value), true);
|
||||||
|
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
|
||||||
|
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
|
||||||
|
assert.equal(isManagedState(null), false);
|
||||||
|
});
|
||||||
|
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
|
||||||
|
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
|
||||||
|
let revoked = false;
|
||||||
|
const bridge = createDeviceBridge({ enabled: true, baseUrl: 'https://api.example/', fetchData: async (url, init) => {
|
||||||
|
calls.push({ path: url.pathname, token: init.headers['X-Board-Token'], body: init.body });
|
||||||
|
if (url.pathname.endsWith('/pairings')) return Response.json({ token: tokenA, state: 'pending', code: '0847', expiresAt: new Date(Date.now()+300000).toISOString() });
|
||||||
|
if (revoked) return Response.json({ error: 'secret raw upstream' }, { status: 410 });
|
||||||
|
if (url.pathname.endsWith('/heartbeat')) return Response.json({ ok: true });
|
||||||
|
const cityId = init.headers['X-Board-Token'] === tokenB ? 2 : 1;
|
||||||
|
return Response.json({ state: 'paired', device: { ...value.device, cityId, cityName: `City ${cityId}`, stopName: 'Stop', settings: { ...defaults, stopId: cityId } } });
|
||||||
|
} });
|
||||||
|
const factoryCalls = [], factory = ({cityId}) => ({ config: () => ({cityId}), board: async stopId => ({cityId,stop:{id:stopId}}) });
|
||||||
|
const defaultClient = { config: () => ({ ...value.config, cityId: 1 }), board: async () => assert.fail('managed requests must use the per-city client') };
|
||||||
|
const server = createApp(defaultClient, {}, bridge, options => { factoryCalls.push(options.cityId); return factory(options); }).listen(0,'127.0.0.1');
|
||||||
|
await new Promise((r,j)=>{server.once('listening',r);server.once('error',j)});t.after(()=>new Promise(r=>server.close(r)));
|
||||||
|
const base=`http://127.0.0.1:${server.address().port}`;
|
||||||
|
const request=(path, opts={})=>fetch(base+path,opts);
|
||||||
|
assert.equal((await request('/api/board?stopId=1')).status,401);
|
||||||
|
const pair = await request('/api/device/pair', { method:'POST',headers:{'Content-Type':'application/json',Origin:base},body:'{}' });
|
||||||
|
assert.equal(pair.status,200); assert.equal((await pair.json()).token,undefined);
|
||||||
|
const cookie = pair.headers.get('set-cookie');assert.match(cookie,/HttpOnly/);assert.match(cookie,/SameSite=Strict/);
|
||||||
|
assert.equal((await request('/api/device/pair',{method:'POST',headers:{'Content-Type':'application/json',Origin:'https://evil.example'},body:'{}'})).status,403);
|
||||||
|
const a={Cookie:`ridebus_board_device=${tokenA}`},b={Cookie:`ridebus_board_device=${tokenB}`};
|
||||||
|
assert.equal((await (await request('/api/device/status',{headers:a})).json()).config.cityId,1);
|
||||||
|
assert.equal((await (await request('/api/board?stopId=1',{headers:a})).json()).cityId,1);
|
||||||
|
assert.equal((await (await request('/api/board?stopId=2',{headers:b})).json()).cityId,2);
|
||||||
|
assert.deepEqual(factoryCalls,[1,2]);assert.equal((await request('/api/board?stopId=2',{headers:a})).status,409);
|
||||||
|
assert.equal((await request('/api/device/heartbeat',{method:'POST',headers:{...a,'Content-Type':'application/json'},body:'{"appliedRevision":1}'})).status,200);
|
||||||
|
revoked=true; const result=await request('/api/device/status',{headers:a}); assert.equal(result.status,410);assert.deepEqual(await result.json(),{error:'device_revoked'});
|
||||||
|
assert.equal((await request('/api/board?stopId=1',{headers:a})).status,410);
|
||||||
|
assert.ok(calls.every(c=>!c.path.includes('/admin')));
|
||||||
|
});
|
||||||
43
test/fixtures/device-flow-server.mjs
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Local end-to-end fixture: real device service, router, admin UI and board bridge.
|
||||||
|
// The database and transport data are explicit test fixtures. Never deploy this file.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createApp } from '../../server/index.mjs';
|
||||||
|
import { createDeviceBridge } from '../../server/device.mjs';
|
||||||
|
import { createDemo } from '../../src/lib/demo.ts';
|
||||||
|
const apiRequire = createRequire(new URL('../../../polotsk-transit-api/backend/package.json', import.meta.url));
|
||||||
|
process.env.JWT_SECRET = 'local-board-fixture-only-never-production';
|
||||||
|
apiRequire.cache[apiRequire.resolve('./src/config/database.js')] = { exports: { query: async () => ({ rows: [] }) } };
|
||||||
|
const express = apiRequire('express'), jwt = apiRequire('jsonwebtoken');
|
||||||
|
const { createBoardDevicesRouter } = apiRequire('./src/routes/boardDevices.js');
|
||||||
|
const { createBoardDeviceService } = apiRequire('./src/services/boardDeviceService.js');
|
||||||
|
const { memoryRepository } = apiRequire('./test/helpers/boardDeviceMemoryRepository.js');
|
||||||
|
const repository = memoryRepository(), service = createBoardDeviceService(repository);
|
||||||
|
const api = express(); api.use(express.json());
|
||||||
|
let managementUnavailable = false;
|
||||||
|
api.post('/__fixture/connection', (req,res) => { managementUnavailable = req.body.offline === true; res.json({ offline: managementUnavailable }); });
|
||||||
|
api.use('/api/v1/board-devices', (_req,res,next) => managementUnavailable ? res.status(503).json({error:'fixture_offline'}) : next());
|
||||||
|
const user = { id: 1, username: 'board-fixture', full_name: 'Тестовый оператор', role: 'admin' };
|
||||||
|
api.post('/api/v1/auth/login', (req, res) => { if(req.body.username !== 'board-fixture' || req.body.password !== 'fixture-only') return res.status(401).json({error:'Тестовый логин: board-fixture / fixture-only'}); res.json({ user, accessToken:jwt.sign(user,process.env.JWT_SECRET,{expiresIn:'1h'}),refreshToken:'local-fixture-refresh' }); });
|
||||||
|
api.get('/api/v1/auth/me', (_req,res)=>res.json(user));
|
||||||
|
api.post('/api/v1/auth/logout', (_req,res)=>res.json({ok:true}));
|
||||||
|
api.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req,res,next) => { const id=Number(req.get('X-City-Id')||1);if(![1,2].includes(id))return res.status(404).json({error:'City not found'});req.city={id};next(); } }));
|
||||||
|
api.get('/api/v1/cities', (_req,res)=>res.json([{id:1,name:'Полоцк',slug:'polotsk',emblem:'🏛️'},{id:2,name:'Новополоцк',slug:'novopolotsk',emblem:'🏙️'}]));
|
||||||
|
api.get('/api/v1/stops', (req,res)=>res.json(repository.stops.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
|
||||||
|
api.get('/api/v1/routes', (req,res)=>res.json(repository.routesData.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
|
||||||
|
api.get('/api/v1/alerts/active',(_req,res)=>res.json([]));api.get('/api/v1/holidays/upcoming',(_req,res)=>res.json([]));api.get('/api/v1/sync/status',(_req,res)=>res.json({status:'ready',database:{schedules_count:2}}));
|
||||||
|
const adminDist=fileURLToPath(new URL('../../../polotsk-transit-api/frontend/dist/',import.meta.url));
|
||||||
|
api.use(express.static(adminDist));api.get('*',async(_req,res)=>res.type('html').send((await readFile(`${adminDist}/index.html`,'utf8')).replace('</body>','<div style="position:fixed;bottom:6px;right:10px;z-index:1000;background:#fff8;padding:4px;font-size:11px">Тестовый стенд · без рабочей БД</div></body>')));
|
||||||
|
await new Promise(r=>api.listen(5190,'127.0.0.1',r));
|
||||||
|
const makeClient=({cityId=1,cityName='Полоцк',defaultStopId=1,stopName='Швейная фабрика'}={})=>({
|
||||||
|
config:()=>({cityId,cityName,defaultStopId,stopName,configured:true,refreshSeconds:20,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}),
|
||||||
|
stops:async()=>repository.stops.filter(s=>s.city_id===cityId),
|
||||||
|
board:async stopId=>({...createDemo(),cityId,cityName,stop:{id:stopId,name:repository.stops.find(s=>s.id===stopId)?.name||stopName,direction:'Тестовые данные'},mode:'live'}),
|
||||||
|
timetable:async()=>{throw new Error('no timetable in fixture');},coordinates:async()=>null,
|
||||||
|
});
|
||||||
|
const bridge=createDeviceBridge({enabled:true,baseUrl:'http://127.0.0.1:5190/',adminUrl:'http://127.0.0.1:5190/devices'});
|
||||||
|
const board=createApp(makeClient(),{weather:async()=>{throw new Error('no weather in fixture');}},bridge,makeClient);
|
||||||
|
const boardDist=fileURLToPath(new URL('../../dist/',import.meta.url));board.use(express.static(boardDist));board.get('/{*path}',(_req,res)=>res.sendFile(`${boardDist}/index.html`));
|
||||||
|
await new Promise(r=>board.listen(5191,'127.0.0.1',r));
|
||||||
|
console.log('Device flow fixture: admin http://127.0.0.1:5190/devices; board http://127.0.0.1:5191/');
|
||||||