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

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


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

ссылка на оригинал статьи https://habr.com/ru/articles/1065082/