Перейти к основному содержимому

Устройство монорепо

Один репозиторий, npm workspaces, apps/* и packages/*. Разделение простое: в packages лежит то, что не знает, где оно работает, в apps — то, что знает.

Что в каком каталоге

КаталогЧто этоЗачем именно так
apps/kioskприложение на планшете (Expo / React Native)единственный, кто держит последовательный порт и говорит с платой замков. 🟡 пишется
apps/apiсервер (NestJS + SQLite)коды, аренды, журнал, команды устройствам. 🟡 пишется
apps/panelвеб-панель оператора (Next.js)✅ карта ячеек, аренды и коды, журнал, тревоги, привязка каналов, объезд
apps/docsэта документация (Docusaurus)собирается в статику, раздаётся nginx с собственного имени docs.…
packages/lock-protocol⭐ разговор с платой замковвся эмпирика живого железа; читать первым
packages/lock-simэмулятор платыплата отключена, разработка идёт на нём
packages/domainбизнес-логика без привязки к платформеодни и те же правила на устройстве и на сервере
packages/contractszod-схемы обмена устройства, панели и сервераединственное описание формата: типы выводятся из схем, а не пишутся рядом руками
packages/i18nLV (по умолчанию), RU, EN, LT, ETпропущенный перевод — ошибка сборки, а не пустая кнопка на уличном экране
packages/design-tokensтокены дизайн-системы Elvaroисточник один (CSS), остальное генерируется, расхождение ловит тест
infraразвёртывание на двух машинах Oracleчетыре имени, TLS, выкладка. Состояние — в infra/README.md

Прошивка, образы и золотой бэкап железа лежат отдельно и в монорепо не переезжают: там несколько гигабайт двоичных файлов.

Почему бизнес-логика вынесена в пакет, а не написана в киоске

Правила про ячейки нужны в двух местах сразу: на планшете (он решает, что показать клиенту и какой канал открыть) и на сервере (он решает, что показать оператору и что записать в журнал). Если написать их дважды, они разъедутся — и разъедутся молча, потому что оба варианта будут «работать».

Поэтому packages/domain не знает ни про React, ни про HTTP, ни про Android: на вход — данные, на выход — решение. Это же делает его тестируемым на ноутбуке, где платы нет.

packages/lock-protocol — читать первым

Три слоя, и границы между ними существенны:

ФайлЧто делает
src/protocols.tsсборка и разбор кадров. Перенесён с живого железа без правок
src/guard.tsбарьер запрещённых кадров, обязателен на каждой отправке
src/frame-reader.tsсборка кадров из потока байтов, с тайм-аутом простоя

Правило пользования: собирать кадры через safeOpenFrame / safeStatusFrame, читать поток через FrameReader. Прямые openFrame / statusFrame оставлены для тестов и для случаев, когда кадр заведомо не уходит в порт.

Что важно знать про остальные пакеты

packages/contracts — схемы обмена

Схемы на zod и есть единственное описание формата: типы выводятся из них (z.infer). Руками написанный тип рядом со схемой рано или поздно разъезжается с проверкой, и разъезд тихий — сервер принимает, устройство считает отправленным, а поле называлось иначе.

🔴 Граница, которую нельзя размывать: на устройстве нет персональных данных ни в каком виде. В схемах устройства — только хеши кодов и непрозрачные ссылки; всё человеческое живёт в схемах панели. Постамат стоит на улице, залочка не сделана намеренно, содержимое /data надо считать читаемым посторонним.

Три вещи, которые выглядят избыточно и таковыми не являются: три метки времени у события, идентификатор загрузки рядом со счётчиком событий, отзыв кода отдельной записью (в дельта-синхронизации исчезновение из выдачи неотличимо от «не менялось»). Почему именно так — состояние ячейки и сторожевой таймер.

packages/i18n — пять языков

Опорный словарь — латышский, из него выводится тип ключей. Добавили ключ туда — четыре остальных словаря немедленно перестают компилироваться, и это задумано: пустая кнопка на уличном экране означает, что человек не понял, что нажимать, и ушёл без инструмента.

Длина строк проверяется тестом, а не глазами: экран 800 × 1280 не переносит и не ужимает, он обрезает.

Литовский и эстонский не вычитаны носителями

Оба словаря написаны с нуля и носителями языка не проверялись. Перед вводом в эксплуатацию их надо показать носителям — иначе первое, что увидит клиент в Литве, будет машинный перевод. Помечено и в шапках самих словарей.

packages/design-tokens — почему генерация, а не копия

Дизайн-система живёт в CSS-переменных соседнего репозитория. Панели этого достаточно, а React Native CSS-переменные не понимает вовсе. Третья копия токенов, набитая руками, разъезжается с источником — в этом проекте так уже случилось. Поэтому источник один, остальное генерируется, а тест падает при расхождении.

apps/panel — строго статическая сборка

Ни одного серверного действия, ни одного обработчика маршрутов: панель раздаётся nginx с машины, где один гигабайт памяти и уже живут nginx и сервер API. Попытка добавить серверный код не «просто не заработает» — сборка упадёт, и это правильно.

Данные панель берёт запросами к API из браузера. Подробности — apps/panel/README.md, там же честный список того, что нарисовано, но пока не работает.

Четыре файла, которые невозможно воссоздать

Они перенесены с живого железа и содержат эмпирику, добытую платой, которой больше нет. Комментарии в них — не украшение, а единственный носитель этого знания. Не причёсывать.

  • packages/lock-protocol/src/protocols.ts — сборка и разбор кадров;
  • apps/kiosk/modules/serial/…/cpp/tty.c — открытие порта в настоящем raw-режиме;
  • apps/kiosk/modules/serial/…/SerialModule.kt — четыре инварианта потока чтения;
  • apps/kiosk/plugins/withHomeLauncher.js — правки Android-проекта, переживающие пересборку.

Если кажется, что комментарий длинный и его надо сократить, — это тот самый случай, когда сокращать нельзя: за каждой строкой стоят потраченные сутки, а перепроверить их уже негде.

Как слои складываются в киоске

экран киоска ──► packages/domain правила: что за состояние, что можно делать


LockBus единственный владелец порта: очередь, приоритеты,
│ задержка на канал, барьер запрещённых кадров

packages/lock-protocol кадры, XOR, разбор потока


modules/serial (нативно) открытие порта в raw-режиме, один дескриптор


/dev/ttyS0 ──► RS485 ──► плата замков

Ключевое место здесь — LockBus в apps/kiosk/src/hardware/. Нативный модуль держит одно соединение, и повторное открытие молча отбирает порт у предыдущего владельца. Пока в приложении был один экран диагностики, это было незаметно; с появлением бизнес-логики два хозяина порта начали бы рвать ответы платы пополам, а симптом неотличим от «плата отвечает мусором». Поэтому порт открывает и закрывает только LockBus, все ходят через него.

Он же делает три вещи сверх обёртки: держит очередь с приоритетами (команда человека важнее фонового опроса), соблюдает паузу между повторными открытиями одного канала и ставит отметку «плата ответила» для сторожевого таймера.

Команды

npm install
npm test # vitest по всему репозиторию
npm run build:packages # сборка пакетов (tsup)
npm run typecheck
npm run lint
npm run format
На этой машине 8 ГБ памяти

Gradle-сборку киоска нельзя запускать одновременно со сборкой образа и с поднятым эмулятором. Симптомы нехватки памяти выглядят как что угодно, только не как нехватка памяти: таймауты, «упавшие» тесты, отвалившийся adb.