TL;DR
Я собрал в своём умном доме автоматизацию, которая:
-
следит за прогнозом осадков (в том числе за краткосрочным nowcast’ом на ближайшие пару часов);
-
когда дождь/снег на подходе — включает подсветку на уличной камере и делает снимок;
-
отправляет снимок в мультимодальную LLM с вопросом «садовая мебель накрыта чехлами?»;
-
если не накрыта — присылает мне в Telegram фото + анимированный радар осадков и две кнопки: «Накрыл» и «Отложить на 3 часа»;
-
пока я не среагировал/не отложил — не спамит; на сухую погоду LLM не дёргается вовсе.
ИИ здесь на двух уровнях. Первый — LLM как слой зрения и рассуждения: принимает кадр с камеры и возвращает ответ по строгой схеме (covered / reason), без парсинга свободного текста. Второй — саму автоматизацию собрал ИИ-агент по SSH и REST/WebSocket API реального сервера (включение сущностей, config-flow, отладка граблей); человек — постановка задачи и приёмка.
Всё — на штатных механизмах платформы, без собственного компонента/интеграции: автоматизации, хелперы, встроенная LLM-интеграция.
1. Зачем это вообще
Классическая сцена: через полчаса дождь, а уличная мебель — диван, кресла, подушки — стоит без чехлов. Платформа умного дома технически «знает» и прогноз, и картинку с камеры. Но связать одно с другим и сделать вывод «пойди накрой» она сама не умеет: нет слоя, который понимает содержимое кадра и сопоставляет его с погодой. Если прозевал дождь, то будешь экстренно заносить в дом подушки и сушить их долго по углам.
Этот слой закрывает LLM: превращает кадр в вывод «мебель открыта / накрыта» с коротким обоснованием.
Само действие — надеть чехлы — делает человек: привода для этого нет, автоматизировать тут физически нечего. Задача системы не «сделать», а вовремя и по делу подсказать. Отсюда два требования:
-
Точность вместо спама. Не слать «накрой мебель» на каждый прогноз дождя. Сначала снимок и проверка через LLM — уведомление уходит, только если мебель действительно открыта.
-
Экономия вызовов. LLM дёргаем не по расписанию, а когда осадки реально близко (nowcast), и не чаще разумного (cooldown, snooze).
Контекст модели недоступен («сегодня чехлы сняли специально, потому что красили»), поэтому последнее слово — за человеком, одним тапом.
2. Архитектура
Весь конвейер — из штатных примитивов платформы:
┌─────────────────────────────────────────────────────────┐ │ Триггеры автоматизации │ │ • каждые 30 минут │ │ • nowcast осадков (2ч) поднялся выше 0 │ │ • состояние погоды сменилось на "дождь" │ └───────────────────────────┬─────────────────────────────┘ │ ┌──────────────────▼────────────────────┐ │ Условия (дёшево, без обращения к LLM):│ │ • не в режиме "отложено" (snooze) │ │ • дождь реально близко (nowcast/ │ │ прогноз/текущее состояние) │ │ • прошло > 3ч с прошлой реальной │ │ проверки (cooldown) │ └──────────────────┬────────────────────┘ │ (иначе — стоп, LLM не вызывается) ┌──────────────────▼───────────────────┐ │ 1. Включить подсветку камеры │ │ 2. Снять кадр во временный каталог │ │ 3. Выключить подсветку │ └──────────────────┬───────────────────┘ │ ┌──────────────────▼────────────────────┐ │ LLM-зрение (AI Task): │ │ вход: кадр + вопрос │ │ выход (structured): {covered, reason}│ └──────────────────┬────────────────────┘ │ covered == false? ┌──────────────────▼───────────────────┐ │ Telegram: фото + радар + кнопки │ │ [✅ Накрыл] [😴 Отложить 3ч] │ └──────────────────┬───────────────────┘ │ callback от кнопки ┌──────────────────▼───────────────────┐ │ Вторая автоматизация: │ │ выставить snooze_until, ответить │ │ на callback, отписать в чат │ └──────────────────────────────────────┘
Компоненты:
-
Хост. Старенький Raspberry Pi 3 (4 ядра ARM, ~1 ГБ RAM), платформа умного дома Home Assistant (HA) работает в Docker-контейнере. Каталог конфигурации примонтирован с хоста. Ресурсов в обрез — что позже и аукнется (см. раздел 8.7).
-
Камера. Уличная IP-камера с управляемой подсветкой/ИК. Важно: её функции (снимок, свет) проброшены в платформу как обычные сущности
camera.*иlight.*— поэтому автоматизация не завязана на конкретного вендора. -
Погода. Интеграция, дающая не только прогноз, но и краткосрочный nowcast осадков (мм за ближайшие ~2 часа) и картинку-радар. Именно nowcast — лучший сигнал «дождь вот-вот».
-
LLM. Мультимодальная модель, подключённая к платформе через штатную интеграцию AI Task (об этом ниже).
-
Мессенджер. Telegram-бот в приватной группе, с inline-кнопками и обработкой callback’ов.
3. Почему без собственного компонента
Соблазн — написать свою интеграцию (и я даже начал, но потом решил, что нужно будет выложить в Open Source, а потом нести ответственность за проект — НЕТ). Но всё уже есть в платформе:
|
Шаг |
Штатный механизм |
|---|---|
|
Прогноз/nowcast |
сервис получения прогноза + сущности-сенсоры осадков |
|
Снимок |
сервис |
|
Подсветка |
|
|
LLM-зрение |
AI Task — сервис |
|
Уведомление |
Telegram-бот: |
|
Оркестрация и состояние |
автоматизации + хелперы ( |
Свой код дал бы разве что более удобный UI настройки; функционально — ничего. Меньше кода — меньше поверхности для поддержки.
4. Ключевой кирпич: LLM-зрение через AI Task
Современные платформы умного дома (HA, например) умеют подключать LLM-провайдера (облачного или локального) как «AI Task»-сущность. Дальше в автоматизации доступен сервис, который принимает инструкцию + вложения (картинки) и возвращает структурированный ответ по заданной схеме.
Вызов (сокращённо, в терминах HA):
- service: ai_task.generate_data continue_on_error: true # деградируем мягко, если LLM недоступна data: entity_id: ai_task.<провайдер> task_name: garden_furniture_check instructions: >- Ты смотришь на снимок уличной террасы/сада. Скоро осадки. Определи, накрыта ли садовая мебель (диван, кресла, подушки) защитными чехлами. Если что-то открыто — covered=false. Если слишком темно или обзор закрыт — covered=false и объясни в reason. structure: # схема ответа = гарантированный формат covered: selector: boolean: {} description: true, если мебель защищена чехлами required: true reason: selector: text: {} description: одно короткое предложение с описанием required: true attachments: - media_content_id: media-source://media_source/local/weatherwatch_ai.jpg media_content_type: image/jpeg response_variable: result
Два важных момента:
-
Structured output. Мы не парсим свободный текст модели, а сразу получаем
result.data.covered(bool) иresult.data.reason(строка). Это резко упрощает логику ниже:covered != true— и всё. -
Вложение — только через media-source. Поле
attachmentsпринимает не путь к файлу, а идентификатор медиа-источника видаmedia-source://media_source/local/<файл>. А это значит, что снимок надо класть в каталог, который платформа считает медиа-источником (у меня — контейнерный/media), а не просто куда-то на диск.
Ответ на реальном кадре выглядел так:
{ "covered": false, "reason": "Часть мебели открыта и не накрыта чехлами." }
5. Полная автоматизация проверки
Ниже — основная автоматизация целиком (идентификаторы сущностей обобщены, chat_id заменён плейсхолдером).
- id: weatherwatch_garden_furniture alias: WeatherWatch - проверка садовой мебели перед дождём mode: single max_exceeded: silent trigger: - platform: time_pattern # каждые 30 минут minutes: "/30" - platform: numeric_state # nowcast осадков поднялся выше 0 entity_id: sensor.precipitation_forecast_total above: 0 - platform: state # состояние стало "дождь" entity_id: weather.local to: rainy condition: # не в режиме "отложено" (Human in the Loop, см. ниже) - condition: template value_template: >- {{ as_timestamp(now()) > (state_attr('input_datetime.weatherwatch_snooze_until','timestamp') | float(0)) }} action: - service: weather.get_forecasts continue_on_error: true target: { entity_id: weather.local } data: { type: daily } response_variable: fc - variables: bad_conditions: [rainy, pouring, snowy, snowy-rainy, hail, lightning-rainy] rain_soon: >- {{ states('weather.local') in bad_conditions or (states('sensor.precipitation_forecast_total') | float(0) > 0) or (fc is defined and (fc.get('weather.local', {}).get('forecast', [])[:2] | selectattr('condition','in', bad_conditions) | list | count > 0)) }} # осадки действительно близко? иначе — стоп, LLM не трогаем (экономия) - condition: template value_template: "{{ rain_soon }}" # cooldown: не запускать проверку чаще раза в 3 часа - condition: template value_template: >- {{ as_timestamp(now()) - (state_attr('input_datetime.weatherwatch_last_check','timestamp') | float(0)) > 10800 }} - service: input_datetime.set_datetime # фиксируем «реальную» проверку target: { entity_id: input_datetime.weatherwatch_last_check } data: { timestamp: "{{ as_timestamp(now()) }}" } # подсветка -> кадр -> подсветка выкл - service: light.turn_on target: { entity_id: light.garden_floodlight } - delay: "00:00:03" # дать сенсору камеры «привыкнуть» - service: camera.snapshot target: { entity_id: camera.garden } data: { filename: "/media/weatherwatch_ai.jpg" } - service: light.turn_off target: { entity_id: light.garden_floodlight } # LLM-зрение - service: ai_task.generate_data continue_on_error: true data: entity_id: ai_task.provider task_name: garden_furniture_check instructions: >- Ты смотришь на снимок сада/террасы. Скоро осадки. Накрыта ли садовая мебель защитными чехлами? Если что-то открыто — covered=false. Если темно/обзор закрыт — covered=false и объясни в reason. structure: covered: { selector: { boolean: {} }, required: true, description: "true, если мебель накрыта чехлами" } reason: { selector: { text: {} }, required: true, description: "короткое описание того, что видно" } attachments: - media_content_id: media-source://media_source/local/weatherwatch_ai.jpg media_content_type: image/jpeg response_variable: result - variables: covered: >- {{ result.data.covered if (result is defined and result.data is defined) else none }} reason: >- {{ result.data.reason if (result is defined and result.data is defined) else 'Ожидается дождь, а проверка ИИ не сработала — проверьте вручную.' }} # уведомляем только если НЕ накрыто - condition: template value_template: "{{ covered != true }}" - service: camera.snapshot # снимок радара во временный каталог target: { entity_id: camera.rain_radar } data: { filename: "/media/weatherwatch_map.gif" } - service: telegram_bot.send_photo data: target: !secret tg_chat_id file: "/media/weatherwatch_ai.jpg" caption: "Накройте садовую мебель — ожидается дождь. {{ reason }}" inline_keyboard: - "✅ Накрыл:/ww_covered, 😴 Отложить 3ч:/ww_snooze3h" - service: telegram_bot.send_animation data: target: !secret tg_chat_id file: "/media/weatherwatch_map.gif" caption: "Радар осадков — nowcast на 2ч: {{ states('sensor.precipitation_forecast_total') }} мм"
6. Обратная связь: кнопки «Накрыл» / «Отложить»
Раз финальное решение остаётся за человеком, ему нужна максимально простая точка управления — прямо в уведомлении. Реализуется тремя вещами.
(1) Inline-кнопки на уведомлении. В сервисе отправки фото есть поле inline_keyboard, где кнопка задаётся строкой Текст:/callback_data:
inline_keyboard: - "✅ Накрыл:/ww_covered, 😴 Отложить 3ч:/ww_snooze3h"
(2) Обработчик callback’а — отдельная автоматизация. При нажатии платформа генерирует событие telegram_callback, где полезная нагрузка лежит в trigger.event.data.data:
- id: weatherwatch_snooze_callback alias: WeatherWatch - кнопки Накрыл/Отложить mode: queued max: 5 trigger: - platform: event event_type: telegram_callback condition: - condition: template value_template: "{{ trigger.event.data.data in ['/ww_covered', '/ww_snooze3h'] }}" action: - variables: is_snooze: "{{ trigger.event.data.data == '/ww_snooze3h' }}" secs: "{{ 10800 if trigger.event.data.data == '/ww_snooze3h' else 64800 }}" - service: input_datetime.set_datetime target: { entity_id: input_datetime.weatherwatch_snooze_until } data: { timestamp: "{{ as_timestamp(now()) + (secs | int) }}" } - service: telegram_bot.answer_callback_query # убрать «часики» на кнопке continue_on_error: true data: callback_query_id: "{{ trigger.event.data.id }}" message: >- {{ 'Отложено на 3 часа' if is_snooze else 'Принято — до вечера не беспокою' }} - service: telegram_bot.send_message continue_on_error: true data: target: !secret tg_chat_id message: >- {{ '😴 Отложено 3ч: ' if is_snooze else '✅ Отмечено как накрыто: ' }}{{ trigger.event.data.from_first }}
(3) Хелпер-состояние. Нажатие выставляет input_datetime.weatherwatch_snooze_until в «сейчас + 3ч» (отложить) или «сейчас + 18ч» (накрыл — до вечера). Основная автоматизация в первом же условии проверяет: если now < snooze_until, она вообще не запускается.
Нажатие выставляет snooze_until, основная автоматизация его учитывает. Без нажатия ничего необратимого не происходит — максимум повторное уведомление, ограниченное cooldown’ом.
7. Контроль стоимости — by design
Мультимодальные вызовы стоят денег, поэтому «дешевизна» зашита в структуру, а не в добрые намерения:
-
Гейт по осадкам. Пока
rain_soonложно, автоматизация останавливается до снимка и LLM. В сухой день обращений к модели — ноль. Проверка каждые 30 минут — это всего лишь дешёвый рендер шаблона. -
Правильный cooldown. Даже во время дождя реальная проверка запускается не чаще раза в 3 часа.
-
Snooze/ack. Ответ человека полностью выключает поток уведомлений на заданное время.
-
Дедупликация действий. Никаких «повторить на всякий случай»: одна ситуация — одно уведомление.
8. Грабли
8.1. Вложение для LLM — только из media-source
ai_task не принимает произвольный путь к файлу: нужен media-source://…. Каталог, отдаваемый как «локальный» веб-контент, media-источником не является. Решение — писать снимок в каталог, зарегистрированный как медиа-директория, и ссылаться на него как media-source://media_source/local/<файл>.
8.2. Telegram и редиректы
Радар осадков доступен по URL, но этот URL отдаёт 301-редирект на CDN с меняющимся адресом. Загрузчик Telegram-интеграции редиректы не проходит и падает с Failed to load URL: 301. Обычный curl -L редирект проходит — отсюда и расхождение. Решение: не отдавать URL, а снять кадр камеры-радара в локальный файл и отправить его.
8.3. Анимированный GIF ≠ фото
Радар — это анимированный GIF. send_photo анимированные GIF не принимает. Нужен send_animation (или send_document) — тогда в чат уезжает «живой» радар, что даже нагляднее.
8.4. Отключённые по умолчанию сущности
Многие полезные сущности интеграции (камера-радар, сенсоры nowcast) по умолчанию выключены в реестре. Включить их пачкой без перезапуска можно через WebSocket-API реестра сущностей:
{ "type": "config/entity_registry/update", "entity_id": "sensor.precipitation_forecast_total", "disabled_by": null }
После этого — «мягкий» reload соответствующей записи конфигурации, без рестарта всей платформы.
8.5. Config flow и subentries через API
Добавление интеграций и настройку «разрешённых чатов» бота можно делать целиком через REST-flow (/api/config/config_entries/flow) и flow вложенных записей (/api/config/config_entries/subentries/flow). Удобно для автоматизации «под ключ», без ручного клика по UI, Клодом.
8.6. Ошибка в cooldown, которую легко не заметить
Первая версия cooldown’а опиралась на «время последнего запуска автоматизации» (last_triggered). Проблема: этот таймстамп обновляется при каждом запуске, в том числе на «сухих» проверках, где осадков нет. В связке с проверкой каждые 30 минут это давало эффект:
-
00:00 — сухая проверка,
last_triggered = 00:00; -
00:30, 01:00, … — cooldown (>3ч) не прошёл, автоматизация даже не стартует;
-
если дождь появился в 01:00 — проверка заблокирована до 03:00.
То есть «сухой» запуск глушил последующую реальную проверку. Решение — вынести состояние в отдельный хелпер input_datetime.weatherwatch_last_check, который обновляется только при реальной проверке (после гейта по осадкам). Теперь сухие срабатывания cooldown не сдвигают.
Мораль: «время последнего запуска» и «время последнего значимого события» — разные вещи. Легко перепутать и получить тихо-неправильную логику.
8.7. Как я уронил сервер в своп
HA у меня крутится на старом Raspberry Pi 3 (~1 ГБ RAM) — и именно поэтому история случилась. Чтобы «безопасно» проверить конфиг, я запустил внутри контейнера штатный скрипт проверки конфигурации. Он поднимает второй экземпляр платформы в памяти. Для гигабайта это перебор: система ушла в своп, перестали отвечать и веб-интерфейс, и SSH (буквально не завершался хендшейк — хосту не хватало ресурсов даже на это). В результате просто выключил из розетки и включил заново.
Выводы:
-
на слабом железе не запускать тяжёлую проверку конфигурации «в бою» рядом с работающим инстансом;
-
перезапуск контейнера/хоста — валидный и быстрый способ выйти из свопа, если конфиг лежит на диске и переживёт рестарт;
-
reload через API валидирует изменения без поднятия второго инстанса — и в большинстве случаев его достаточно.
9. Что дальше
-
Тихие часы — не будить уведомлением, скажем, с 22:00 до 07:00 (для «ночного» дождя копить и присылать заранее вечером).
-
Больше «наблюдателей». Тот же примитив (камера + погодный триггер + вопрос на естественном языке + действие) переиспользуется: «закрыть окна», «занести бельё», «накрыть бассейн перед заморозком».
-
Обратная связь как обучение. Кнопка «на самом деле накрыто» может со временем подстраивать порог/промпт и повышать доверие.
10. Выводы
-
LLM-зрение — недостающий слой рассуждения между камерами и действиями. С нативной AI-Task-интеграцией его вплетаешь без единого своего компонента, а structured output превращает ответ модели в предсказуемые поля, а не в текст, который надо парсить.
-
ИИ-агент сегодня не только пишет код, но и разворачивает систему целиком. Эту автоматизацию он собрал сам: заходил по SSH, ходил в REST/WebSocket API, включал отключённые сущности, проходил config-flow и вычищал грабли. Роль человека сместилась к постановке задачи и приёмке результата.
-
Дешевизна и «не спамить» — это архитектура, а не намерение. Гейты по осадкам, cooldown на реальных событиях и обратная связь пользователя встроены в структуру.
-
Финальное действие оставлено за человеком осознанно, а не по недоделке. Там, где ошибка необратима или нужен контекст, ИИ берёт наблюдение и суждение, решение — за человеком.
Подписывайтесь на канал ТехДир Подсекин, ставьте лайк, вам не сложно, мне — приятно.
ссылка на оригинал статьи https://habr.com/ru/articles/1065082/