LLM смотрит на мой сад: как ИИ-зрение решает, звать ли меня накрыть мебель перед дождём. ha.. ha. home assistant.. ha. home assistant. home automation.. ha. home assistant. home automation. llm.. ha. home assistant. home automation. llm. анализ изображений.. ha. home assistant. home automation. llm. анализ изображений. искусственный интеллект.. ha. home assistant. home automation. llm. анализ изображений. искусственный интеллект. Умный дом.

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

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


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

Автор: WondeRu

Источник