- BrainTools - https://www.braintools.ru -
Я собрал в своём умном доме автоматизацию, которая:
следит за прогнозом осадков (в том числе за краткосрочным nowcast’ом на ближайшие пару часов);
когда дождь/снег на подходе — включает подсветку на уличной камере и делает снимок;
отправляет снимок в мультимодальную LLM с вопросом «садовая мебель накрыта чехлами?»;
если не накрыта — присылает мне в Telegram фото + анимированный радар осадков и две кнопки: «Накрыл» и «Отложить на 3 часа»;
пока я не среагировал/не отложил — не спамит; на сухую погоду LLM не дёргается вовсе.
ИИ здесь на двух уровнях. Первый — LLM как слой зрения [1] и рассуждения: принимает кадр с камеры и возвращает ответ по строгой схеме (covered / reason), без парсинга свободного текста. Второй — саму автоматизацию собрал ИИ-агент по SSH и REST/WebSocket API реального сервера (включение сущностей, config-flow, отладка граблей); человек — постановка задачи и приёмка.
Всё — на штатных механизмах платформы, без собственного компонента/интеграции: автоматизации, хелперы, встроенная LLM-интеграция.
Классическая сцена: через полчаса дождь, а уличная мебель — диван, кресла, подушки — стоит без чехлов. Платформа умного дома технически «знает» и прогноз, и картинку с камеры. Но связать одно с другим и сделать вывод «пойди накрой» она сама не умеет: нет слоя, который понимает содержимое кадра и сопоставляет его с погодой. Если прозевал дождь, то будешь экстренно заносить в дом подушки и сушить их долго по углам.
Этот слой закрывает LLM: превращает кадр в вывод «мебель открыта / накрыта» с коротким обоснованием.
Само действие — надеть чехлы — делает человек: привода для этого нет, автоматизировать тут физически нечего. Задача системы не «сделать», а вовремя и по делу подсказать. Отсюда два требования:
Точность вместо спама. Не слать «накрой мебель» на каждый прогноз дождя. Сначала снимок и проверка через LLM — уведомление уходит, только если мебель действительно открыта.
Экономия вызовов. LLM дёргаем не по расписанию, а когда осадки реально близко (nowcast), и не чаще разумного (cooldown, snooze).
Контекст модели недоступен («сегодня чехлы сняли специально, потому что красили»), поэтому последнее слово — за человеком, одним тапом.
Весь конвейер — из штатных примитивов платформы:
┌─────────────────────────────────────────────────────────┐
│ Триггеры автоматизации │
│ • каждые 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’ов.
Соблазн — написать свою интеграцию (и я даже начал, но потом решил, что нужно будет выложить в Open Source, а потом нести ответственность за проект – НЕТ). Но всё уже есть в платформе:
|
Шаг |
Штатный механизм |
|---|---|
|
Прогноз/nowcast |
сервис получения прогноза + сущности-сенсоры осадков |
|
Снимок |
сервис |
|
Подсветка |
|
|
LLM-зрение |
AI Task — сервис |
|
Уведомление |
Telegram-бот: |
|
Оркестрация и состояние |
автоматизации + хелперы ( |
Свой код дал бы разве что более удобный UI настройки; функционально — ничего. Меньше кода — меньше поверхности для поддержки.
Современные платформы умного дома (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 (строка). Это резко упрощает логику [2] ниже: covered != true — и всё.
Вложение — только через media-source. Поле attachments принимает не путь к файлу, а идентификатор медиа-источника вида media-source://media_source/local/<файл>. А это значит, что снимок надо класть в каталог, который платформа считает медиа-источником (у меня — контейнерный /media), а не просто куда-то на диск.
Ответ на реальном кадре выглядел так:
{ "covered": false, "reason": "Часть мебели открыта и не накрыта чехлами." }
Ниже — основная автоматизация целиком (идентификаторы сущностей обобщены, 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') }} мм"
Раз финальное решение остаётся за человеком, ему нужна максимально простая точка управления — прямо в уведомлении. Реализуется тремя вещами.
(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’ом.
Мультимодальные вызовы стоят денег, поэтому «дешевизна» зашита в структуру, а не в добрые намерения:
Гейт по осадкам. Пока rain_soon ложно, автоматизация останавливается до снимка и LLM. В сухой день обращений к модели — ноль. Проверка каждые 30 минут — это всего лишь дешёвый рендер шаблона.
Правильный cooldown. Даже во время дождя реальная проверка запускается не чаще раза в 3 часа.
Snooze/ack. Ответ человека полностью выключает поток уведомлений на заданное время.
Дедупликация действий. Никаких «повторить на всякий случай»: одна ситуация — одно уведомление.
ai_task не принимает произвольный путь к файлу: нужен media-source://…. Каталог, отдаваемый как «локальный» веб-контент, media-источником не является. Решение — писать снимок в каталог, зарегистрированный как медиа-директория, и ссылаться на него как media-source://media_source/local/<файл>.
Радар осадков доступен по URL, но этот URL отдаёт 301-редирект на CDN с меняющимся адресом. Загрузчик Telegram-интеграции редиректы не проходит и падает с Failed to load URL: 301. Обычный curl -L редирект проходит — отсюда и расхождение. Решение: не отдавать URL, а снять кадр камеры-радара в локальный файл и отправить его.
Радар — это анимированный GIF. send_photo анимированные GIF не принимает. Нужен send_animation (или send_document) — тогда в чат уезжает «живой» радар, что даже нагляднее.
Многие полезные сущности интеграции (камера-радар, сенсоры nowcast) по умолчанию выключены в реестре. Включить их пачкой без перезапуска можно через WebSocket-API реестра сущностей:
{ "type": "config/entity_registry/update",
"entity_id": "sensor.precipitation_forecast_total",
"disabled_by": null }
После этого — «мягкий» reload соответствующей записи конфигурации, без рестарта всей платформы.
Добавление интеграций и настройку «разрешённых чатов» бота можно делать целиком через REST-flow (/api/config/config_entries/flow) и flow вложенных записей (/api/config/config_entries/subentries/flow). Удобно для автоматизации «под ключ», без ручного клика по UI, Клодом.
Первая версия 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 не сдвигают.
Мораль: «время последнего запуска» и «время последнего значимого события» — разные вещи. Легко перепутать и получить тихо-неправильную логику.
HA у меня крутится на старом Raspberry Pi 3 (~1 ГБ RAM) — и именно поэтому история случилась. Чтобы «безопасно» проверить конфиг, я запустил внутри контейнера штатный скрипт проверки конфигурации. Он поднимает второй экземпляр платформы в памяти [3]. Для гигабайта это перебор: система ушла в своп, перестали отвечать и веб-интерфейс, и SSH (буквально не завершался хендшейк — хосту не хватало ресурсов даже на это). В результате просто выключил из розетки и включил заново.
Выводы:
на слабом железе не запускать тяжёлую проверку конфигурации «в бою» рядом с работающим инстансом;
перезапуск контейнера/хоста — валидный и быстрый способ выйти из свопа, если конфиг лежит на диске и переживёт рестарт;
reload через API валидирует изменения без поднятия второго инстанса — и в большинстве случаев его достаточно.
Тихие часы — не будить уведомлением, скажем, с 22:00 до 07:00 (для «ночного» дождя копить и присылать заранее вечером).
Больше «наблюдателей». Тот же примитив (камера + погодный триггер + вопрос на естественном языке + действие) переиспользуется: «закрыть окна», «занести бельё», «накрыть бассейн перед заморозком».
Обратная связь как обучение [4]. Кнопка «на самом деле накрыто» может со временем подстраивать порог/промпт и повышать доверие.
LLM-зрение — недостающий слой рассуждения между камерами и действиями. С нативной AI-Task-интеграцией его вплетаешь без единого своего компонента, а structured output превращает ответ модели в предсказуемые поля, а не в текст, который надо парсить.
ИИ-агент сегодня не только пишет код, но и разворачивает систему целиком. Эту автоматизацию он собрал сам: заходил по SSH, ходил в REST/WebSocket API, включал отключённые сущности, проходил config-flow и вычищал грабли. Роль человека сместилась к постановке задачи и приёмке результата.
Дешевизна и «не спамить» — это архитектура, а не намерение. Гейты по осадкам, cooldown на реальных событиях и обратная связь пользователя встроены в структуру.
Финальное действие оставлено за человеком осознанно, а не по недоделке. Там, где ошибка [5] необратима или нужен контекст, ИИ берёт наблюдение и суждение, решение — за человеком.
Подписывайтесь на канал ТехДир Подсекин [6], ставьте лайк, вам не сложно, мне – приятно.
Автор: WondeRu
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/33799
URLs in this post:
[1] зрения: http://www.braintools.ru/article/6238
[2] логику: http://www.braintools.ru/article/7640
[3] памяти: http://www.braintools.ru/article/4140
[4] обучение: http://www.braintools.ru/article/5125
[5] ошибка: http://www.braintools.ru/article/4192
[6] ТехДир Подсекин: https://t.me/cto_podsekin
[7] Источник: https://habr.com/ru/articles/1065082/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1065082
Нажмите здесь для печати.