2026-08-31 01:32:53 +04:00
2026-08-27 21:17:07 +04:00
2026-08-27 21:17:07 +04:00

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-регистрации.

AdapterRegistryiot-hub-core) — то, что раньше было AdapterService внутри YandexProvider. Раньше он получал список устройств от каждого адаптера по HTTP (см. был класс Scheduler, слал heartbeat каждые 10 секунд) и при запросе от Яндекса заново ходил по HTTP на конкретный адаптер. Теперь адаптеры — это соседние объекты в той же JVM: реестр вызывает их методы напрямую, без сети, без (де)сериализации, без риска "потерять" адаптер из-за сетевого таймаута.

Настраиваемость — на двух уровнях

  1. Рантайм. Каждый адаптер включается/выключается через hub.adapters.<id>.enabled, без пересборки — значение приходит из примонтированного файла конфигурации (см. раздел "Конфигурация без Helm-редеплоя" ниже), не из переменных окружения. Это разбирает аннотация @AdapterComponent + AdapterEnabledCondition (iot-hub-api) — Spring просто не создаёт бин, значит нет ни соединений к внешним API, ни фоновых задач.
  2. Сборка. Модуль adapter-mock подключён к iot-hub-app через Maven-профиль with-mock (включён по умолчанию). Если он не нужен — можно вообще не тащить этот код в итоговый jar/образ:
    mvn -pl iot-hub-app -am package -P '!with-mock'
    

Как добавить новый адаптер

Раньше это описывал Contract.md в корне (реализовать интерфейс, унаследовать HTTP-контроллер, завести бин шедулера, настроить application.properties с собственным хостом). Теперь:

  1. Новый модуль в adapters/, зависимость на iot-hub-api.
  2. Класс implements DeviceAdapter, помеченный @AdapterComponent("имя").
  3. (если нужны настройки) @ConfigurationProperties(prefix = "hub.adapters.имя") — Spring сам подхватит его через @ConfigurationPropertiesScan в HubApplication.
  4. Если у адаптера есть вспомогательные бины с фоновой логикой (клиенты, хранилища токенов, шедулеры) — пометьте их @ConditionalOnAdapter("имя"), чтобы они не пытались стартовать, когда адаптер выключен.
  5. Подключить модуль в 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:
    kubectl -n iot edit configmap iot-hub-config
    kubectl -n iot rollout restart deployment iot-hub
    
    Перезапуск пода нужен, потому что Spring Boot не перечитывает конфигурацию на лету — но это секунды, и Helm/CI в этом не участвуют.

Важный нюанс: 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.xml Lombok был подключен как <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) в рамках текущей задачи — просмотрите код и соберите проект самостоятельно перед тем, как разворачивать его вместо старых сервисов.
S
Description
Кастомные устройства умного дома
Readme
264 KiB
Languages
Java 97.7%
Dockerfile 1.4%
Go Template 0.9%