Iot Hub
Модульный монолит умного дома с Алисой. Заменяет прежнюю связку независимых микросервисов
(YandexProvider, CameraAdapter, KeeneticAdapter, MockAdapter — см. родительскую папку)
одним процессом.
Зачем это сделано
Несколько Spring Boot приложений на маленьком домашнем сервере — это несколько JVM (по 150–300 МБ памяти каждая только на старте), несколько Tomcat/Netty, несколько подов в кластере, несколько Helm-чартов и HTTP-трафик "здесь регистрируюсь" каждые 10 секунд от каждого адаптера к провайдеру — даже когда в доме буквально ничего не поменялось. При этом почти вся "микросервисность" была фиктивной: адаптеры не масштабировались независимо, не переживали отказ провайдера осмысленно (провайдер и так был единой точкой отказа для всей системы), и разворачивались всегда вместе, одним релизом.
Идея этого проекта — оставить пользу модульности (чёткие границы, независимая компиляция/тестирование, лёгкое добавление нового устройства по контракту) и убрать её цену (лишние процессы, сеть, сериализацию, heartbeat).
Архитектура
iot-hub/
├── iot-hub-api/ SPI: DeviceAdapter, @AdapterComponent, модели Yandex API
├── iot-hub-core/ реестр адаптеров, REST "Яндекс Диалоги. Умный дом", OAuth
├── adapters/
│ ├── adapter-camera/ камеры видеонаблюдения (HLS)
│ ├── adapter-keenetic/ VPN-переключатели и датчики присутствия на роутере Keenetic
│ └── adapter-mock/ виртуальные/сценарные выключатели (см. раздел ниже)
└── iot-hub-app/ main(), application.yml, Dockerfile, Helm — единственная сборка
Каждый adapter-* — обычная Maven-библиотека (jar), не Spring Boot приложение: без своего
main(), без своего порта, без своего Helm-чарта. Реализация DeviceAdapter, помеченная
@AdapterComponent("имя"), становится обычным Spring-бином. iot-hub-app подключает нужные
модули в pom.xml, Spring при старте сканирует весь classpath и собирает все найденные
адаптеры в список — это и есть "адаптеры как бины" вместо HTTP-регистрации.
AdapterRegistry (в iot-hub-core) — то, что раньше было AdapterService внутри
YandexProvider. Раньше он получал список устройств от каждого адаптера по HTTP (см. был
класс Scheduler, слал heartbeat каждые 10 секунд) и при запросе от Яндекса заново ходил по
HTTP на конкретный адаптер. Теперь адаптеры — это соседние объекты в той же JVM: реестр
вызывает их методы напрямую, без сети, без (де)сериализации, без риска "потерять" адаптер
из-за сетевого таймаута.
Настраиваемость — на двух уровнях
- Рантайм. Каждый адаптер включается/выключается через
hub.adapters.<id>.enabled, без пересборки — значение приходит из примонтированного файла конфигурации (см. раздел "Конфигурация без Helm-редеплоя" ниже), не из переменных окружения. Это разбирает аннотация@AdapterComponent+AdapterEnabledCondition(iot-hub-api) — Spring просто не создаёт бин, значит нет ни соединений к внешним API, ни фоновых задач. - Сборка. Модуль
adapter-mockподключён кiot-hub-appчерез Maven-профильwith-mock(включён по умолчанию). Если он не нужен — можно вообще не тащить этот код в итоговый jar/образ:mvn -pl iot-hub-app -am package -P '!with-mock'
Как добавить новый адаптер
Раньше это описывал Contract.md в корне (реализовать интерфейс, унаследовать HTTP-контроллер,
завести бин шедулера, настроить application.properties с собственным хостом). Теперь:
- Новый модуль в
adapters/, зависимость наiot-hub-api. - Класс
implements DeviceAdapter, помеченный@AdapterComponent("имя"). - (если нужны настройки)
@ConfigurationProperties(prefix = "hub.adapters.имя")— Spring сам подхватит его через@ConfigurationPropertiesScanвHubApplication. - Если у адаптера есть вспомогательные бины с фоновой логикой (клиенты, хранилища токенов,
шедулеры) — пометьте их
@ConditionalOnAdapter("имя"), чтобы они не пытались стартовать, когда адаптер выключен. - Подключить модуль в
iot-hub-app/pom.xml.
Никакого HTTP-контроллера, никакого шедулера регистрации — этого слоя больше не существует.
adapter-mock: сценарные выключатели
adapter-mock — это не только "тупые" тестовые выключатели для проверки протокола, но и
способ дать сценарию Алисы дёрнуть произвольный внешний HTTP-эндпоинт. Каждое устройство в
hub.adapters.mock.devices описывается именем и действием (action), которое выполняется
только при попытке включить выключатель (on: true) — выключение всегда просто меняет
состояние, без побочных эффектов:
action.type |
Что делает |
|---|---|
NONE |
Ничего, обычный виртуальный выключатель — реально хранит состояние (по умолчанию, если action не задан) |
HTTP_REQUEST |
Отправляет запрос action.method (GET/POST) на action.url, без тела |
HTTP_REQUEST-выключатель не хранит своё состояние: запрос отправлен и забыт, ответа от
получателя мы не ждём и не проверяем. Алисе на само включение отвечаем, что оно прошло успешно,
но при следующем же опросе состояния выключатель снова покажет "выключен" — как кнопка, а не
тумблер. NONE-выключатель, наоборот, реально переключается и держит состояние.
Устройство появляется в Алисе с id mock-switch-<name>. Неудачный HTTP-запрос (эндпоинт
недоступен и т.п.) логируется, но не мешает ни смене состояния выключателя, ни ответу Алисе.
action.content-type (по умолчанию application/json) — Content-Type исходящего запроса. Тела
у запроса нет, но заголовок всё равно важен: RestTemplate под капотом использует
java.net.HttpURLConnection, а тот сам подставляет Content-Type: application/x-www-form-urlencoded
на любой POST/PUT, если заголовок не задан явно, — и Spring-получатель со строгим consumes
отвечает на такой запрос 415 Unsupported Media Type (реальный кейс, воспроизведённый на живом
вебхуке). При необходимости настраивается под конкретный эндпоинт.
Пример конфига — конкретный кейс из задачи: сценарий "пробуждение" в Алисе понимает, когда
пользователь проснулся, и включает виртуальный выключатель wake-up-trigger, который дёргает
вебхук интеграции, составляющей распорядок дня в календаре:
hub:
adapters:
mock:
enabled: true
devices:
- name: wake-up-trigger
action:
type: HTTP_REQUEST
method: POST
url: http://wakeup-integration.local:9000/trigger
# content-type: application/json # по умолчанию, можно не указывать
- name: test-switch
action:
type: NONE
Список — структура, а не переменная окружения, задаётся тем же механизмом, что и вся остальная конфигурация адаптеров — см. следующий раздел.
adapter-keenetic: VPN-выключатели и датчики присутствия
Один и тот же роутер Keenetic отдаёт в Алису устройства двух назначений — задаются списком
types в hub.adapters.keenetic.devices (по аналогии с adapter-mock, см. выше):
types содержит |
Что это |
|---|---|
VPN |
Выключатель "маршрутизировать через VPN" (исходное и единственное поведение адаптера до этого раздела) |
SENSOR |
Датчик присутствия: "человек дома", если MAC его телефона сейчас активен в сети Wi-Fi роутера |
types — список, а не одно значение, потому что один и тот же MAC часто нужен для обоих
назначений сразу: например, телефон владельца одновременно переключает VPN и служит датчиком
присутствия. В этом случае types: [VPN, SENSOR] собирается в одно устройство Алисы — с
capabilities (выключатель) и properties (датчик) одновременно, а не в два раздельных.
Это штатно для протокола "Яндекс Диалоги. Умный дом" (так же, например, устроена розетка с
выключателем и счётчиком мощности на одном устройстве). Id при этом всегда keenetic-vpn-switch-<mac>
(id VPN-выключателя) — чтобы уже привязанное в Алисе устройство не расклеилось, если позже
добавить ему SENSOR. Только если у устройства нет VPN вообще (одиночный датчик присутствия),
используется отдельный id keenetic-presence-<mac>.
hub:
adapters:
keenetic:
enabled: true
router-url: "http://192.168.0.0:79/rci/"
devices:
- name: "Роутер"
mac: "aa:bb:cc:dd:ee:ff"
types: [VPN]
- name: "Сергей"
mac: "11:22:33:44:55:66"
types: [VPN, SENSOR]
- name: "Ирина дома"
mac: "22:33:44:55:66:77"
types: [SENSOR]
Датчик присутствия использует поле active из ответа Keenetic RCI (show ip hotspot) —
это признак "подключён к Wi-Fi прямо сейчас", а не policy (назначение VPN-маршрутизации,
никак не связано с фактическим подключением устройства). Один MAC может встречаться в ответе
несколькими записями одновременно — присутствие фиксируется, если активна хотя бы одна.
MAC определяется один раз при первом подключении к сети и после этого не меняется: начиная с iOS 18 приватный Wi-Fi-адрес Apple по умолчанию стабильный для каждой отдельной Wi-Fi-сети (режим "Fixed"), меняется только при "забыть сеть" + повторном подключении или явном сбросе в настройках — то есть для присутствия членов семьи, чьи iPhone уже когда-то подключались к этой домашней сети, MAC можно считать постоянным идентификатором.
Датчик присутствия (и часть свойств у комбинированного VPN+SENSOR устройства) — read-only:
Алиса опрашивает его через обычный /v1.0/user/devices/query, но раз в SENSOR_POLL_MS
(по умолчанию 5 минут) его дополнительно проактивно опрашивает DeviceChangeNotifier
(iot-hub-core) и при смене значения (detected/not_detected) сам шлёт в Яндекс
push-уведомление об изменении состояния — так в приложении Яндекса накапливается история
присутствия, а не только "последнее известное на момент случайного запроса Алисы" значение.
Механизм общий для любых будущих датчиков (не только Keenetic) — подробнее см. Javadoc
DeviceChangeNotifier/YandexStateNotifier.
Запуск
Локально:
mvn -pl iot-hub-app -am package
java -jar iot-hub-app/target/iot-hub-app.jar
Docker Compose (проще всего для одного домашнего сервера без Kubernetes):
docker compose up -d --build
Helm (если уже используете Kubernetes/microk8s дома — заменяет собой прежние чарты
deployment/* из папок адаптеров):
helm install iot-hub deployment/iot-hub -n iot --create-namespace
См. deployment/iot-hub/templates/NOTES.txt — там команды для создания секретов.
CI/CD (Gitea Actions → сборка образа → helm upgrade в кластер при пуше в main/master)
описан в .gitea/README.md — там же разовая настройка (RBAC, секреты Gitea).
Конфигурация
Всё — в iot-hub-app/src/main/resources/application.yml, один файл вместо нескольких
application.properties. Он делится на две части с принципиально разной природой изменений:
| Переменная | По умолчанию | Назначение |
|---|---|---|
SERVER_PORT |
8080 |
порт хаба (был порт 8080 у YandexProvider) |
CONFIG_PATH |
./config/ |
каталог с config.json (OAuth-токены Яндекса) |
REGISTRY_REFRESH_MS |
30000 |
как часто реестр обновляет список устройств адаптеров |
SENSOR_POLL_MS |
300000 |
как часто DeviceChangeNotifier опрашивает датчики и шлёт push в Яндекс при изменении (см. раздел про датчики присутствия) |
Это редко меняющиеся параметры самого процесса — они остались обычными переменными окружения.
Вкл/выкл адаптеров и вся их статическая конфигурация (hub.adapters.camera/keenetic/mock
целиком — enabled, список камер, устройства Keenetic (MAC + тип), mock-устройства) — это то,
что меняется по ходу жизни дома (добавили камеру, завели новый сценарий) и переменными
окружения больше не
задаётся. Исключение — логин/пароль администратора роутера Keenetic
(hub.adapters.keenetic.login/password, нужны с KeeneticOS 5.2 alpha): это учётные данные,
поэтому они не в файле/ConfigMap, а в Secret и передаются переменными окружения
HUB_ADAPTERS_KEENETIC_LOGIN/HUB_ADAPTERS_KEENETIC_PASSWORD (см. "Секреты" ниже). Их источник — файл, примонтированный через SPRING_CONFIG_IMPORT (см. следующий
раздел), который полностью переопределяет соответствующую секцию application.yml. Если файл
не примонтирован (например, локальный запуск без Docker/Helm) — действуют "заводские" значения
из application.yml: все адаптеры включены, списки пустые.
Конфигурация без Helm-редеплоя
Список камер/устройств Keenetic/mock-устройств и флаги enabled меняются часто и не должны требовать
helm upgrade (тем более что CI/CD теперь автоматически его гоняет при каждом пуше в
main/master — лишний повод для внепланового редеплоя не нужен). Поэтому эта часть
конфигурации живёт отдельно от кода приложения — в файле, а не в переменных окружения самого
Deployment:
- Docker Compose — файл
hub-config.yml(копияhub-config.yml.example, в git не попадает) примонтирован volume'ом. Поменяли —docker compose restart(неup --build, пересборка не нужна). - Kubernetes/Helm — тот же файл рендерится в ConfigMap (
templates/configmap.yaml, ключhub-config.yml) и монтируется в/config-extra/hub-config.yml. Между релизами его можно редактировать напрямую, без Helm:Перезапуск пода нужен, потому что Spring Boot не перечитывает конфигурацию на лету — но это секунды, и Helm/CI в этом не участвуют.kubectl -n iot edit configmap iot-hub-config kubectl -n iot rollout restart deployment iot-hub
Важный нюанс: helm upgrade (в том числе автоматический, из CI/CD пайплайна при пуше в
main/master) перезапишет ConfigMap обратно в то, что лежит в values.yaml/git — это
Helm-релиз, и он не знает про правки, сделанные напрямую через kubectl. Поэтому:
- для быстрого эксперимента или временной правки — редактируйте ConfigMap напрямую, это безопасно и никак Helm/git не трогает;
- если правку нужно сохранить навсегда (переживёт следующий деплой из CI) — продублируйте её
и в
deployment/iot-hub/values.yaml(adapters.*), закоммитьте — тогда она уже часть "заводской" конфигурации.
Секреты — что исправлено по пути
При переносе кода обнаружилась и устранена одна вещь, которую стоит знать:
YandexProvider/deployment/yandex-provider/templates/configmap.yaml хранил реальный
OAuth-токен Яндекса, homeUserId, clientId и clientSecret прямо в Kubernetes ConfigMap
(ConfigMap не шифруется и виден любому, у кого есть доступ на чтение в namespace). В новом
Helm-чарте это заменено на Kubernetes Secret, монтируемый в /config (см.
deployment/iot-hub/templates/NOTES.txt).
Рекомендация: если старый ConfigMap с этими значениями всё ещё лежит в вашей истории git — токен стоит считать скомпрометированным и перевыпустить (заново авторизовать OAuth Яндекса).
Логин/пароль администратора роутера Keenetic — по той же причине (учётные данные, не
конфигурация) не в hub-config.yml/ConfigMap, а в отдельном Secret, переданном в под
переменными окружения HUB_ADAPTERS_KEENETIC_LOGIN/HUB_ADAPTERS_KEENETIC_PASSWORD
(secretKeyRef, optional: true — под стартует и без этого secret'а, если adapter-keenetic
не используется):
kubectl -n iot create secret generic iot-hub-keenetic-config \
--from-literal=login=admin --from-literal=password='...'
Docker Compose — те же значения через .env (KEENETIC_LOGIN/KEENETIC_PASSWORD), см.
комментарий в docker-compose.yml.
Что перенесено не один в один
- Уведомление о звонке в домофон (
YandexDoorbellNotifier, былоYandexApiвYandexProvider) — перенесено как есть, но, как и в исходном проекте, не зарегистрировано как Spring-бин: в текущем срезе кода не было адаптера домофона, который бы его вызывал. Класс на месте и готов к использованию, если такой адаптер появится. - Отладочные HTTP-фильтры Keenetic (
CurlLoggingFilter,ContentCachingRequestWrapper,FilterConfigиз исходногоKeeneticAdapter/.../ll/) не переносились — это чисто отладочное логирование curl-эквивалента запросов, не влияющее на функциональность. При необходимости их легко добавить обратно вadapter-keenetic. - Дублирующиеся пакеты моделей Keenetic (
model/keenetic/set/keenetic/set/...иmodel/keenetic/set/keenetic/state/...— судя по всему, случайность при копировании кода в исходном проекте) сведены к одному чистому набору:model/set/*иmodel/state/*. - В исходных
pom.xmlLombok был подключен как<scope>annotationProcessor</scope>— такого scope в Maven не существует (это конфигурация Gradle, не Maven); здесь везде обычный, задокументированный официальным Lombok способ —<scope>provided</scope>. - Адаптер Starline удалён. Изначально в проект перенесли и
adapter-starline(автосигнализация), но он больше не нужен: пока шёл перенос, появилась полноценная штатная интеграция Starline, и необходимость в собственном адаптере отпала. Модуль, его Maven-профиль (with-starline), связанные переменные окружения и Helm-секрет удалены. Зависимость наspring-cloud-starter-openfeign(использовалась только Starline-клиентом через Feign) тоже убрана.
Дальнейшие возможные шаги (не сделано сейчас, чтобы не менять всё разом)
- Java 17/21 + Spring Boot 3 — меньше памяти на JVM, но требует отдельной проверки всех
адаптеров (особенно
javax.*→jakarta.*). Сейчас проект специально оставлен на Java 11 / Spring Boot 2.7.5 — том же стеке, что и исходные микросервисы, — чтобы перенос архитектуры не смешивался с переносом платформы. - "Удалённый" адаптер как частный случай. Если когда-нибудь один адаптер понадобится
физически вынести на другую машину (например, мини-компьютер рядом с оборудованием) —
для этого не нужно возвращаться к микросервисам целиком: достаточно реализации
DeviceAdapter, которая внутри проксирует вызовы по HTTP на внешний хост, и зарегистрировать её как обычный@AdapterComponent. Остальная система об этом даже не узнает. - Этот README не собирался и не проверялся сборкой (
mvn package) в рамках текущей задачи — просмотрите код и соберите проект самостоятельно перед тем, как разворачивать его вместо старых сервисов.