TL;DR

Я собрал в своём умном доме автоматизацию, которая:

  1. следит за прогнозом осадков (в том числе за краткосрочным nowcast’ом на ближайшие пару часов);

  2. когда дождь/снег на подходе — включает подсветку на уличной камере и делает снимок;

  3. отправляет снимок в мультимодальную LLM с вопросом «садовая мебель накрыта чехлами?»;

  4. если не накрыта — присылает мне в Telegram фото + анимированный радар осадков и две кнопки: «Накрыл» и «Отложить на 3 часа»;

  5. пока я не среагировал/не отложил — не спамит; на сухую погоду 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

сервис получения прогноза + сущности-сенсоры осадков

Снимок

сервис camera.snapshot

Подсветка

light.turn_on / switch.turn_on (любая сущность на выбор)

LLM-зрение

AI Task — сервис ai_task.generate_data с вложениями и структурированным выводом

Уведомление

Telegram-бот: send_photo, send_animation, inline-клавиатура, answer_callback_query

Оркестрация и состояние

автоматизации + хелперы (input_datetime)

Свой код дал бы разве что более удобный 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

Два важных момента:

  1. Structured output. Мы не парсим свободный текст модели, а сразу получаем result.data.covered (bool) и result.data.reason (строка). Это резко упрощает логику ниже: covered != true — и всё.

  2. Вложение — только через 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

Мультимодальные вызовы стоят денег, поэтому «дешевизна» зашита в структуру, а не в добрые намерения:

  1. Гейт по осадкам. Пока rain_soon ложно, автоматизация останавливается до снимка и LLM. В сухой день обращений к модели — ноль. Проверка каждые 30 минут — это всего лишь дешёвый рендер шаблона.

  2. Правильный cooldown. Даже во время дождя реальная проверка запускается не чаще раза в 3 часа.

  3. Snooze/ack. Ответ человека полностью выключает поток уведомлений на заданное время.

  4. Дедупликация действий. Никаких «повторить на всякий случай»: одна ситуация — одно уведомление.


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 на реальных событиях и обратная связь пользователя встроены в структуру.

  • Финальное действие оставлено за человеком осознанно, а не по недоделке. Там, где ошибка необратима или нужен контекст, ИИ берёт наблюдение и суждение, решение — за человеком.


Подписывайтесь на канал ТехДир Подсекин, ставьте лайк, вам не сложно, мне - приятно.

Комментарии (0)