Четвертый год строю умный дом. Кажется, понял, что в нем самое главное. Не какое решение выбрать в качестве сервера, не какие технологии использовать, не каких производителей девайсы закупать — это все детали.
Главное — вовремя остановиться. И у меня это не получилось, судя по тому, что я потратил прорву времени на это.
Давно хотел сделать такую панель, но нормального гайда не нашел, пришлось придумывать собственный велосипед.
Зачем
Умный дом прекрасен и без тачпанели. Большая часть магии все равно под капотом и обходится без интерфейсов, чисто на автоматизациях. А такие команды как «включить свет» и «сделать теплее», удобно отдавать голосом через умную колонку. Или через телеграм-бот.
В общем, меня все устраивало, пока я не поставил датчики влажности почвы в цветочные горшки. Сообщение в телеграм «Полейте белый цветок на подоконнике!» пару раз ставило в тупик, так как и белых цветков и подоконников в доме немало. Я задумался, и тут все завертелось…
Идея в том, что тачпанель позволяет буквально на ходу, одним взглядом оценить обстановку в доме, запросить дополнительную информацию и отдать нужную команду просто ткнув пальцем. Новый уровень удобства.
Железо
Подробно расписывать, что такое умный дом, и как он устроен конкретно у меня, смысла не вижу, статей об этом полно. Мой основан на Home Assistant, просто потому, что в него можно интегрировать вообще все. У меня, например, 52 Zigbee- и 38 WiFi-устройств, 2 Modbus-шлюза для управления климатической техникой, IP-домофон, и шлюз LoRa для связи с GPS-трекером в машине. Все это от разных производителей, почти у каждого своя экосистема и свои заморочки. Для возведения таких вавилонов Home Assistant вне конкуренции.
Железо для тачпанели выбирал довольно долго. Поначалу рассматривал два очевидных варианта, от которых в итоге отказался: Android-планшет, и промышленный компьютер с тачскрином.
Казалось бы, планшет — идеальная железка для панели, вешай на стену и наслаждайся. Многие так и делают, а через полгода выясняется, что зря. От постоянного подключения к адаптеру питания аккумулятор планшета накрывается (а то и вздувается), и все это приходится нести в ремонт.
Кто-то пытается наколхозить обходное решение: например, ставит умную розетку, которая включает питание только когда аккумулятор разрядится до 10%. Или же вообще извлекает из планшета аккумулятор и подпаивает питание напрямую.
В любом случае этот путь мне не годился, потому что даже если все получится, диагональ панели будет очень маленькой.
У промышленн��х компов с Aliexpress другие проблемы: либо дорого (40+ тыс. руб.), либо непонятная надежность и никакая производительность. А то и все сразу.
В итоге я приобрел на Авито старый сенсорный монитор iiyama T2252MSC за 16 000 руб. (верный выбор, подсмотрел в одном телеграм-канале про умный дом) и микро-ПК Findarling T5B за 8 000 руб. (ошибка, тормозит, пришлось заменить на более современный).

Следующий вопрос — как и куда вешать эту панель. Когда я 8 лет назад начал строить дом, перерасход бюджета вынудил исключить из проекта не самые необходимые инженерные системы — в частности, под нож пошел проводной умный дом. В итоге никакой инфраструктуры под умный дом не заложено, приходится импровизировать.

Но мне повезло — в холле когда-то планировалось повесить монитор домофона, поэтому туда были выведены витая пара и питание. Оставалось только выдолбить перфоратором нишу для оборудования, и закрепить кронштейн для монитора.

Сначала пытался обойтись WiFi, но в итоге задействовал витую пару — толстая керамическая стена и монитор сильно мешают сигналу, связь иногда терялась, при том, что ближайшая точка доступа висит в 5 метрах.
Софт
На подключенном к тачпанели компе установлен Windows 10 с Chrome в режиме киоска и больше ничего. И все равно производительности древнего Intel Atom не хватило, слишком тяжелый фронтенд я наворотил. Выражается это в периодических фризах картинки с камер и в общей недостаточной отзывчивости интерфейса. В итоге поменял на чуть более громоздкий комп с Intel N97, с некоторым трудом удалось разместить его в той же нише.

Chrome с этого компа в режиме киоска ломится на сервер HA под специальным бесправным пользователем, и показывает доступные ему панели.

Самое трудоемкое — сконфигурировать панель поэтажного плана в Home Assistant. Код этой панели у меня занимает 20 тысяч строк. К счастью, не весь код пишется вручную, основную часть можно сгенерить специальным плагином.
Чтобы нарисовать поэтажный план, я поставил Sweet Home 3D, и наборы моделей к нему. Затем потратил несколько дней, чтобы научиться им пользоваться, и чтобы получилось что-то похожее на реальный дом.
Совет: чем больше на плане деталей, тем он лучше смотрится. Поэтому у меня, например, на плане второго этажа размещена модель стола для пинг-понга, хотя этот стол никак с умным домом не взаимодействует. Но он там есть и хорошо заполняет пустое пространство.

Когда план более-менее готов, ставим плагин home-assistant-floor-plan. Он нужен, чтобы сгенерить код панели Home Assistant. Экономит очень много времени (напоминаю, 20 тысяч строк!).
Теперь размещаем источники света. Те, которые должны быть интерактивными, называем по идентификаторам их сущностей в Home Assistant. Если на плане должно что-то исчезать и появляться — называем этот объект по соответствующему бинарному датчику или переключателю. Например, фигурка человека на унитазе привязана к датчику присутствия binary_sensor.prisutstvie_v_sanuzle_presence. А машина на парковке привязана к вспомогательной сущности input_boolean.car_home, значение которой ставится true в случае, если расстояние машины до дома меньше 20 метров.


Не забываем расставить и неинтерактивные источники света. Например, у меня в прихожей свет не контролируется умным домом. Но если на плане не поставить в прихожую источник света, то на рендере для ночного времени этой комнаты вообще не будет видно.
Совет: выберите и сохраните вид для окна предпросмотра (CTRL+ALT+R). Если вы потом будете вносить изменения в план (а вы будете), малейший сдвиг — и придется перерендерить все варианты плана. Поэтому перед каждым рендерингом восстанавливаем сохраненный вид.

Когда все готово, рендерим тестовую картинку. Только так вы сможете увидеть финальный вид вашего плана, с освещением и тенями. Вам точно придется делать это несколько раз, чтобы пофиксить все косяки и подобрать подходящий размер картинки в пикселях. Я в итоге остановился на 1458x1010, что для FHD-монитора хорошо подошло.

Если все ок, жмем Tools → Home Assistant Floor Plan и настраиваем плагин — конфигуратор панели.

Прокликиваем каждую сущность в списке Other enitities и настраиваем.

Display type: оставляем Icon
Display condition: условие отображения иконки сущности. Если нам нужна иконка для управления объектом, то Always, если не нужна, то Never. Ну либо When on/When Off, чтобы иконка была видна только когда соответствующая ей сущность была true либо false. Например, для ворот у меня иконка включена, так как через нее я их открываю и закрываю, а для присутствия в санузле — выключена, так как управлять там нечем. Если пофантазировать, можно на эту иконку привязать TTS через Яндекс.Станцию, чтобы она на 10й громкости предлагала освободить туалет, но сортирные шутки быстро надоедают.
Tap action: действие по нажатию. Toggle переключает сущность, More info показывает значение. More info актуально для датчиков и объектов с поп‑апами управления, а вот выключателям света, приводам ворот и т. д. ставим Toggle.
Double tap action и Hold action позволяют настроить реакцию на двойное и продолжительное нажатие. Пока не нашел этому применения.
Display furniture: отображение объекта на плане. Always если всегда, State is.. только когда его сущность в home assistant имеет указанное значение, State isn't — только когда сущность имеет любое другое значение. Например, у меня машина на плане отображается только когда значение input_boolean.car_home равняется true, аналогично с воротами и присутствием в санузле.
Ставим рендерер какой нравится (я разницы между ними не вижу), а качество High. Sensitivity мало на что влияет, я оставляю дефолтную 10.
В Render Times можно добавить для какого времени суток будут рендериться изображения. Дело в том, что плагин позволяет настроить так, чтобы картинка менялась с течением времени. Например, в 10:00 вы увидите на плане свой дом в утреннем освещении, в 15:00 в дневном, а в полночь — в темноте. Звучит круто, по факту же изменения малозаметны. Ну, тени движутся, это да. Но цена этого значительна: на каждое указанное время будет рендериться полный пакет изображений, а это долго.
Чтобы потом в вашей панели рендеры плана переключались по времени, надо создать в HA вспомогательный датчик sensor.time_as_number_utc, с шаблоном {{states("sensor.time_utc").split(":") | join | int}}. Это позволяет установить смену картинки плана хоть каждые полчаса. Я поигрался этим, и в итоге сделал проще. После восхода у меня дневной вид, после заката — ночной.

Каждый источник освещения и каждый исчезающий объект удвоит вам количество рендеров. Если у вас только одна интерактивная лампа, и вы указали одно время дня, то плагин сделает две картинки: с включенной лампой и выключенной. Если две интерактивные лампы, картинки будет уже четыре, и т.д. У меня 448 рендеров первого этажа и 24 второго. Это при том, что я в итоге остановился всего на двух вариантах плана, на дневном и ночном. Вариант с почасовой сменой плана потребует в 12 раз больше рендеров.
Проблема тут в том, что Sweet Home 3D рендерит эти картинки очень долго. Использовать GPU он не умеет, и на моем i7-14700 эти 448 картинок рендерятся около 15 часов со 100% загрузкой CPU.
Все настроили, проходимся по чеклисту:
Все ли интерактивные источники света указаны в панели Detected Lights?
Есть ли ошибки в именах сущностей источников света?
Все ли исчезающие объекты указаны в панели Other entities?
Все ли исчезающие объекты сконфигурированы правильно?
Установлены ли размеры изображения?
Если все ок, нажимаем Start и идем спать.
У меня Sweet Home 3D при рендеринге иногда крашится, но, к счастью, после перезапуска продолжает с того места, где прервался (если стоит галочка Use existing renders).
Конфигурация панели поэтажного плана
Для корневого макета панели берем Sidebar, чтобы не занимать всю ширину монитора планом дома. На боковую панель выносим самые полезные данные, не имеющие привязки к конкретной локации в доме — в моем случае это напряжения и потребляемая мощность на фазах, управление вентиляцией и кондиционером, почасовой прогноз погоды и т.д.

Основную, левую часть макета занимает карточка Vertical Stack. Так как в доме два этажа, то в этом макете две карточки Conditional, содержимое которых показывается попеременно, в зависимости от значения переменной input_select.floorplan_floor. Внутри каждой Conditional-карточки сидит карточка Picture-Elements, которая, собственно, и содержит план дома и все элементы, которые на него накладываютс��.

Базовый код Picture-Elements генерит для нас упомянутый плагин. Все картинки с планами всех этажей, которые мы нарендерили в Sweet Home, мы складываем в \\homeassistant\config\www\floorplan, а полученный код плана этажа из файла floorplan.yaml (который генерит плагин) копипастим в текстовый редактор панелей Home Assistant, в карточку Picture-Elements. Так мы получим интерактивный план этажа дома с управлением светом и исчезающими элементами.

Теперь нужно добавить всякое дополнительное: показания датчиков, управление термостатами, информационные иконки полива цветов и т.д.
Совет: редактируйте код панели в стороннем текстовом редакторе, и затем копипастите в редактор панели Home Assistant. Дело в том, что редактор панели перерисовывает превью панели при изменении любого символа, из-за этого все люто тормозит при попытке редактировать сложные панели.
К сожалению, все дополнительные элементы приходится выравнивать методом приближений — чуть поменяли расположение, сохранили панель, посмотрели, в каком месте теперь элемент, снова подправляем.
Распишу, какие элементы есть в моей панели.
Переключение этажа. Создаем вспомогательную сущность input_select.floorplan_floor с возможными значениями «Первый этаж», «Второй этаж» и т.д., прописываем отображение панели Conditional в зависимости от значения input_select.floorplan_floor.

На каждый этаж добавляем элемент переключения.
Скрытый текст
type: icon icon: mdi:floor-plan style: left: 3% top: 3% tap_action: action: perform-action perform_action: input_select.select_next target: entity_id: input_select.floorplan_floor data: cycle: true
Показания датчиков. Самое простое — это элемент типа state-label, привязанный к объекту, чье значение вы хотите показать на плане.
Скрытый текст
type: state-label entity: sensor.datchik_v_detskoi_temperature style: left: 52% top: 16% color: black font-weight: bold font-size: 16px background: null

По нажатию можно посмотреть, как показания менялись во времени.

Если датчик питается от батареи, полезно рядом с его показаниями разместить индикатор низкого заряда, это элемент типа conditional, отображаемый при низком значении объекта батареи датчика.
Скрытый текст
type: conditional conditions: - condition: numeric_state entity: sensor.datchik_v_detskoi_battery below: 5 elements: - type: icon icon: mdi:battery-alert-variant-outline style: left: 54% top: 15% color: "#ff0000" transform: translate(-50%, -50%) scale: 60% entity: sensor.datchik_v_detskoi_battery
Управление шторами. Простой элемент state-icon без изысков, так как его поп-ап достаточно функционален.
Скрытый текст
type: state-icon entity: cover.shtora_v_kabinete_3 tap_action: action: more-info style: left: 17% top: 53% border-radius: 50% text-align: center background: "#D8BFD8" opacity: 80%
Тыкаем в иконку — всплывает поп-ап, где можно управлять шторой.

Иконка полива цветка. Три иконки (элемента) на самом деле — красная, желтая и зеленая, отображаются на одном и том же месте в зависимости от показаний датчика влажности почвы.
Скрытый текст
type: conditional conditions: - condition: numeric_state entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture above: 49 elements: - type: icon icon: mdi:flower entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture style: top: 38% left: 78% color: green background: "#D8BFD8" border-radius: 5px title: цветок на буфете полит
type: conditional conditions: - condition: numeric_state entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture below: 50 - condition: numeric_state entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture above: 16 elements: - type: icon icon: mdi:flower entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture style: top: 38% left: 78% color: yellow background: "#D8BFD8" border-radius: 5px title: цветок на буфете надо полить
type: conditional conditions: - condition: numeric_state entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture below: 16 elements: - type: icon icon: mdi:flower entity: sensor.vlazhnost_tsvetka_na_bufete_soil_moisture style: top: 38% left: 78% color: red background: "#D8BFD8" border-radius: 5px title: цветок на буфете сохнет
Термостаты. Вот тут можно поизвращаться. Дело в том, что у панели типа Picture-Elements набор разрешенных элементов очень убог, а обычную интерфейсную карточку для Home Assistant разместить на этой панели нельзя. При этом можно разместить любую кастомную карточку для Home Assistant — вроде тех, что устанавливаются через HACS. Идиотизм, но приходится действовать так: находим более-менее подходящую кастомную карточку, устанавливаем через HACS, и пожалуйста, добавляем через элемент типа custom. Например, мне пришлось устанавливать карточку Better Thermostat, несмотря на то что меня вполне устроила бы и стандартная карточка термостата.
Тут нужно два элемента: иконка для вызова карточки термостата (чтобы не загромождать план дома, если термостатов несколько, а показывать по нажатию), и сама карточка термостата типа custom, вложенная в conditional.
Скрытый текст
type: state-icon style: left: 22% top: 63% border-radius: 50% border: 2px solid red text-align: center background-color: rgba(255, 255, 255, 0.3) opacity: 100% entity: input_boolean.koshkin_dom state_color: true tap_action: action: toggle
Создаем вспомогательную сущность input_boolean.koshkin_dom, и если она true, то показываем элемент с карточкой термостата.
Скрытый текст
type: conditional conditions: - condition: state entity: input_boolean.koshkin_dom state: "on" elements: - type: custom:better-thermostat-ui-card entity: climate.koshkin_dom disable_window: false disable_off: false name: Кошкин дом style: left: 30% top: 40% width: 300px border-radius: 10px border: 2px solid black text-align: center opacity: 95%

Но можно так не извращаться и обойтись довольно убогим информационным поп-апом термостата.
Скрытый текст
type: state-icon icon: mdi:cat style: left: 22% top: 63% border-radius: 50% border: 2px solid red text-align: center background-color: rgba(255, 255, 255, 0.3) opacity: 100% entity: climate.koshkin_dom state_color: true tap_action: action: more-info

В итоге я перешел к второму варианту, в качестве бонуса получил изменение цвета иконки на оранжевый, когда термостат греет.
Внимание! Обогревать кошек — это важно. Не пренебрегайте этой обязанностью.

Картинка с камеры. Просто элемент типа image.
Скрытый текст
type: image camera_view: live camera_image: camera.camera2 style: left: 62% top: 87% width: 300px border: 2px solid black border-radius: 10px text-align: center background-color: rgba(255, 255, 255, 0.3) opacity: 100%
Не всегда работает стабильно, картинка иногда фризится, почему — пока не разобрался. Но в целом удобно.

Тапнув по картике, получим поп-ап с картинкой побольше.

Картинка с отслеживаемым объектом. В моем случае это последнее появление кота на камере с отметкой, когда он был зафиксирован. Мне это нужно, чтобы понимать, откуда — с крыльца или веранды — звать котов, когда они изволят вернуться с гулянки.

Определением объектов в кадре занимается сервер видеонаблюдения Frigate, с ним интегрирован Home Assistant. Интеграция Frigate создает в HA сущности с изображениями последнего обнаруженного объекта каждого опознаваемого типа, меня интересует объект типа cat.
Скрытый текст
type: image entity: image.camera2_cat camera_view: auto style: left: 75% top: 85% width: 120px border-radius: 50% border: 2px solid black text-align: center background-color: rgba(255, 255, 255, 0.3) opacity: 100%
Сгенерить подпись с количеством времени, прошедшего с момента появления объекта в кадре, оказалось не очень просто. Пришлось в configuration.yaml прописать специальные датчики (тут прибегнул к вайбкодингу, собственных знаний не хватило).
Скрытый текст
time_since_camera2_cat_change: friendly_name: 'Время с последнего изменения camera2_cat' value_template: >- {% set snapshot_time_str = states('image.camera2_cat') %} {% if snapshot_time_str is none or snapshot_time_str in ['unavailable', 'unknown', ''] %} Неизвестно {% else %} {% set snapshot_ts = as_timestamp(snapshot_time_str, 0) %} {% set current_ts = now().timestamp() %} {% if snapshot_ts == 0 %} Ошибка формата {% else %} {% set diff_secs = current_ts - snapshot_ts %} {% if diff_secs < 0 %} Ошибка времени {% elif diff_secs >= 86400 %} Давно {% else %} {% set hours = (diff_secs // 3600) | int %} {% set minutes = ((diff_secs % 3600) // 60) | int %} {% if hours >= 1 %} {{ hours }} ч {{ minutes }} мин назад {% elif minutes >= 1 %} {{ minutes }} мин назад {% else %} Меньше минуты назад {% endif %} {% endif %} {% endif %} {% endif %} icon_template: mdi:clock
Ну и сама подпись, элемент типа state-label.
Скрытый текст
type: state-label entity: sensor.time_since_camera2_cat_change style: left: 74.8% top: 92% color: black font-weight: bold font-size: 12px background: white border-radius: 10px opacity: 40%
Управление роботом-пылесосом. Это то, на что я потратил больше всего времени. Сначала вешаем иконку в место расположения док-станции.
Скрытый текст
type: state-icon entity: vacuum.roborock_m1s_6ec6_robot_cleaner title: null style: top: 53.32% left: 44.64% border-radius: 50% text-align: center background-color: rgba(255, 255, 255, 0.3) opacity: 100% tap_action: action: more-info
Получаем по клику простенькую панель управления, где нет возможности указать, где убираться.

После этого, конечно, хочется большего, и мы переходим к отображению по клику кастомной карточки с картой и возможностью выбора комнаты, области, или точки, где убираться.
Для этого сначала отдельно настраиваем кастомную карточку пылесоса. У меня пылесос Dreame, к нему подошла интеграция Dreame vacuum от Tasshack.


После того, как интеграция сгенерировала конфиг карточки, а мы его поправили как нам надо, можно переносить этот код на наш план дома в элемент типа conditional, внутри которого сидит элемент типа custom с нашей карточкой. Код получается огромный — зато можно все настроить.
Скрытый текст
type: conditional conditions: - condition: state entity: input_boolean.vacuum_map_show state: "on" elements: - type: custom:xiaomi-vacuum-map-card map_source: camera: camera.x40_ultra_complete_map calibration_source: camera: true entity: vacuum.x40_ultra_complete vacuum_platform: Tasshack/dreame-vacuum icons: - icon: mdi:play conditions: - entity: vacuum.x40_ultra_complete value_not: cleaning - entity: vacuum.x40_ultra_complete value_not: error - entity: vacuum.x40_ultra_complete value_not: returning tooltip: Start tap_action: action: call-service service: vacuum.start service_data: entity_id: vacuum.x40_ultra_complete - icon: mdi:pause conditions: - entity: vacuum.x40_ultra_complete value_not: docked - entity: vacuum.x40_ultra_complete value_not: idle - entity: vacuum.x40_ultra_complete value_not: error - entity: vacuum.x40_ultra_complete value_not: paused tooltip: Pause tap_action: action: call-service service: vacuum.pause service_data: entity_id: vacuum.x40_ultra_complete - icon: mdi:stop conditions: - entity: vacuum.x40_ultra_complete value_not: docked - entity: vacuum.x40_ultra_complete value_not: idle - entity: vacuum.x40_ultra_complete value_not: error - entity: vacuum.x40_ultra_complete value_not: paused tooltip: Stop tap_action: action: call-service service: vacuum.stop service_data: entity_id: vacuum.x40_ultra_complete - icon: mdi:home-map-marker conditions: - entity: vacuum.x40_ultra_complete value_not: docked - entity: vacuum.x40_ultra_complete value_not: returning tooltip: Return to base tap_action: action: call-service service: vacuum.return_to_base service_data: entity_id: vacuum.x40_ultra_complete - icon: mdi:map-marker tooltip: Locate tap_action: action: call-service service: vacuum.locate service_data: entity_id: vacuum.x40_ultra_complete - menu_id: cleaning_mode icon: mdi:broom tooltip: Cleaning mode label: Сухая conditions: - entity: vacuum.x40_ultra_complete attribute: cleaning_mode value: Sweeping entity: select.x40_ultra_complete_cleaning_mode available_values_attribute: options icon_mapping: sweeping: mdi:broom mopping: mdi:water-opacity sweeping_and_mopping: mdi:hydro-power mopping_after_sweeping: mdi:water-polo tap_action: action: call-service service: select.select_option service_data: option: sweeping entity_id: select.x40_ultra_complete_cleaning_mode - menu_id: cleaning_mode icon: mdi:water-opacity tooltip: Cleaning mode label: Влажная conditions: - entity: vacuum.x40_ultra_complete attribute: cleaning_mode value: Mopping entity: select.x40_ultra_complete_cleaning_mode available_values_attribute: options icon_mapping: sweeping: mdi:broom mopping: mdi:water-opacity sweeping_and_mopping: mdi:hydro-power mopping_after_sweeping: mdi:water-polo tap_action: action: call-service service: select.select_option service_data: option: mopping entity_id: select.x40_ultra_complete_cleaning_mode - menu_id: cleaning_mode icon: mdi:hydro-power tooltip: Cleaning mode label: Сухая и влажная conditions: - entity: vacuum.x40_ultra_complete attribute: cleaning_mode value: Sweeping and mopping entity: select.x40_ultra_complete_cleaning_mode available_values_attribute: options icon_mapping: sweeping: mdi:broom mopping: mdi:water-opacity sweeping_and_mopping: mdi:hydro-power mopping_after_sweeping: mdi:water-polo tap_action: action: call-service service: select.select_option service_data: option: sweeping_and_mopping entity_id: select.x40_ultra_complete_cleaning_mode - menu_id: cleaning_mode icon: mdi:water-polo tooltip: Cleaning mode label: Влажная после сухой conditions: - entity: vacuum.x40_ultra_complete attribute: cleaning_mode value: Mopping after sweeping entity: select.x40_ultra_complete_cleaning_mode available_values_attribute: options icon_mapping: sweeping: mdi:broom mopping: mdi:water-opacity sweeping_and_mopping: mdi:hydro-power mopping_after_sweeping: mdi:water-polo tap_action: action: call-service service: select.select_option service_data: option: mopping_after_sweeping entity_id: select.x40_ultra_complete_cleaning_mode - menu_id: fan_speed icon: mdi:fan-remove label: Тихая conditions: - entity: vacuum.x40_ultra_complete attribute: fan_speed value: Silent tooltip: Change fan speed tap_action: action: call-service service: vacuum.set_fan_speed service_data: entity_id: vacuum.x40_ultra_complete fan_speed: Silent - menu_id: fan_speed icon: mdi:fan-speed-1 label: Обычная conditions: - entity: vacuum.x40_ultra_complete attribute: fan_speed value: Standard tooltip: Change fan speed tap_action: action: call-service service: vacuum.set_fan_speed service_data: entity_id: vacuum.x40_ultra_complete fan_speed: Standard - menu_id: fan_speed icon: mdi:fan-speed-2 label: Мощная conditions: - entity: vacuum.x40_ultra_complete attribute: fan_speed value: Strong tooltip: Change fan speed tap_action: action: call-service service: vacuum.set_fan_speed service_data: entity_id: vacuum.x40_ultra_complete fan_speed: Strong - menu_id: fan_speed icon: mdi:fan-speed-3 label: Турбо conditions: - entity: vacuum.x40_ultra_complete attribute: fan_speed value: Turbo tooltip: Change fan speed tap_action: action: call-service service: vacuum.set_fan_speed service_data: entity_id: vacuum.x40_ultra_complete fan_speed: Turbo - icon: mdi:fan-alert conditions: - entity: vacuum.x40_ultra_complete attribute: fan_speed value_not: Silent - entity: vacuum.x40_ultra_complete attribute: fan_speed value_not: Standard - entity: vacuum.x40_ultra_complete attribute: fan_speed value_not: Strong - entity: vacuum.x40_ultra_complete attribute: fan_speed value_not: Turbo tooltip: Change fan speed tap_action: action: call-service service: vacuum.set_fan_speed service_data: entity_id: vacuum.x40_ultra_complete fan_speed: Silent tiles: - tile_id: status entity: vacuum.x40_ultra_complete label: Чем занят attribute: status icon: mdi:robot-vacuum translations: sleeping: Спит starting: Поехал charger disconnected: Нет питания idle: Ждет remote control active: Удаленное управление cleaning: Убирается returning home: Возвращается manual mode: Ручной режим charging: Заряжается charging problem: Проблема с зарядкой paused: На паузе spot cleaning: Чистит точку error: Ошибка shutting down: Выключается updating: Обновляет прошивку docking: Заходит в базу going to target: Идет на цель zoned cleaning: Зональная уборка segment cleaning: Уборка сегмента emptying the bin: Выгружает пыль charging complete: Зарядка завершена device offline: Не в сети - tile_id: battery_level entity: vacuum.x40_ultra_complete label: Заряд attribute: battery_level icon_source: vacuum.x40_ultra_complete.attributes.battery_icon unit: "%" - tile_id: fan_speed entity: vacuum.x40_ultra_complete label: Мощность attribute: fan_speed icon: mdi:fan translations: silent: Тихая standard: Обычная medium: Средняя strong: Мощная turbo: Турбо auto: Auto gentle: Нежная - tile_id: mop_pad_humidity attribute: mop_pad_humidity label: Швабры icon: mdi:water-percent entity: vacuum.x40_ultra_complete precision: 0 translations: wet: Мокрые dry: Сухие map_modes: - name: Зональная уборка icon: mdi:select-drag run_immediately: false coordinates_rounding: true coordinates_to_meters_divider: 1000 selection_type: MANUAL_RECTANGLE max_selections: 20 repeats_type: EXTERNAL max_repeats: 3 service_call_schema: service: dreame_vacuum.vacuum_clean_zone service_data: zone: "[[selection]]" repeats: "[[repeats]]" entity_id: "[[entity_id]]" predefined_selections: [] variables: {} - name: Уборка на точке icon: mdi:map-marker-plus run_immediately: false coordinates_rounding: true coordinates_to_meters_divider: 1000 selection_type: MANUAL_POINT max_selections: 999 repeats_type: EXTERNAL max_repeats: 3 service_call_schema: service: dreame_vacuum.vacuum_clean_spot service_data: points: "[[selection]]" repeats: "[[repeats]]" entity_id: "[[entity_id]]" predefined_selections: [] variables: {} - name: Передвинуть робота icon: mdi:map-marker-radius run_immediately: false coordinates_rounding: true coordinates_to_meters_divider: 1000 selection_type: MANUAL_POINT max_selections: 1 repeats_type: NONE max_repeats: 1 service_call_schema: service: dreame_vacuum.vacuum_goto service_data: x: "[[point_x]]" "y": "[[point_y]]" entity_id: "[[entity_id]]" predefined_selections: [] variables: {} - template: vacuum_clean_segment predefined_selections: - id: "1" icon: name: mdi:bookshelf x: -150 "y": 8350 label: text: Кабинет x: -150 "y": 8350 offset_y: 35 outline: - - -1750 - 6850 - - 1750 - 6850 - - 1750 - 9850 - - -1750 - 9850 - id: "2" icon: name: mdi:bed-king-outline x: 3150 "y": 4600 label: text: Спальня x: 3150 "y": 4600 offset_y: 35 outline: - - 1700 - 2400 - - 4700 - 2400 - - 4700 - 6600 - - 1700 - 6600 - id: "3" icon: name: mdi:toilet x: 2950 "y": 1750 label: text: Малый санузел x: 2950 "y": 1750 offset_y: 35 outline: - - 1750 - 1100 - - 4650 - 1100 - - 4650 - 2400 - - 1750 - 2400 - id: "4" icon: name: mdi:foot-print x: -150 "y": 4400 label: text: Холл x: -150 "y": 4400 offset_y: 35 outline: - - -2900 - 1050 - - 1700 - 1050 - - 1700 - 6850 - - -2900 - 6850 - id: "5" icon: name: mdi:foot-print x: -3950 "y": 2150 label: text: Прихожая x: -3950 "y": 2150 offset_y: 35 outline: - - -4750 - 150 - - -2900 - 150 - - -2900 - 4050 - - -4750 - 4050 - id: "6" icon: name: mdi:sofa-outline x: -1350 "y": -1700 label: text: Гостиная x: -1350 "y": -1700 offset_y: 35 outline: - - -2700 - -4200 - - 900 - -4200 - - 900 - 1100 - - -2700 - 1100 - id: "7" icon: name: mdi:toilet x: -1500 "y": 5200 label: text: Санузел x: -1500 "y": 5200 offset_y: 35 outline: - - -2750 - 3800 - - -600 - 3800 - - -600 - 6550 - - -2750 - 6550 - id: "8" icon: name: mdi:archive-outline x: 950 "y": 2900 label: text: Кладовка x: 950 "y": 2900 offset_y: 35 outline: - - 600 - 1700 - - 1350 - 1700 - - 1350 - 4150 - - 600 - 4150 - id: "9" icon: name: mdi:chef-hat x: 2750 "y": -1900 label: text: Кухня x: 2750 "y": -1900 offset_y: 35 outline: - - 900 - -4600 - - 4750 - -4600 - - 4750 - 750 - - 900 - 750 additional_presets: [] style: left: 36% top: 48% width: 450px border-radius: 10px border: 2px solid black text-align: center opacity: 95%
Теперь следующий этап: хочется, чтобы иконка робота не просто сидела на месте док-станции, а перемещалась в соответствии с местоположением самого робота.
Иконку с переменными координатами панель Picture-Elements сделать так просто мне не дала, пришлось снова обратиться к нейросети, которая посоветовала использовать card-mod. Это кастомная UI-интеграция, которая позволяет использовать CSS-стили внутри карточек Home Assistant.
Скрытый текст
type: state-icon entity: input_boolean.vacuum_map_show tap_action: action: toggle icon: mdi:robot-vacuum card_mod: style: > {% set vac = state_attr('camera.x40_ultra_complete_map','vacuum_position') %} {% set raw_angle = vac.a | float(0) %} {% set offset = 180 %} {% set angle = ((offset - raw_angle) % 360) %} :host { position: absolute; left: {{ states('sensor.vacuum_left_px_new') }}px; top: {{ states('sensor.vacuum_top_px_new') }}px; width: 25px; height: 25px; padding: 3px; box-sizing: border-box; background-color: rgba(255,255,255,0.9); border: 1px solid #e0e0e0; border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; transform: rotate({{ angle }}deg) !important; transform-origin: center center !important; z-index: initial !important; } ha-icon { color: #000; width: 20px; height: 20px; margin: 0; transform: rotate({{ angle }}deg) !important; transform-origin: center center !important;
Основная сложность в преобразовании координат робота на плоскости его карты в координаты на плане дома, который не плоский, а имеет перспективу. Эту задачу я пока решил не до конца — в некоторых комнатах иконка пока еще заметно смещается от реального местоположения робота. Я разбил карту на 25 квадратов, вручную погонял робота по этажу, и составил таблицу из 40 калибровочных точек, где записал соответствие координат робота на его карте и координат на моем плане. Затем скормил эту таблицу Gemini 2.5, и попросил просчитать коэффициенты формул для билинейной интерполяции координат.
Скрытый текст
gemini-2.5-pro-exp
17.04.2025 at 21:52
Здравствуйте! Понимаю вашу задачу: нужно отобразить положение робота-пылесоса (vacuum_position из camera.x40_ultra_complete_map) на плане этажа в Home Assistant, учитывая разницу в системах координат, ориентации и перспективное искажение плана. У вас есть набор калибровочных точек, связывающих координаты робота и пиксельные координаты на плане.
Ваша идея с разделением плана на сетку 5x5 и расчетом локальных коэффициентов для каждого квадрата — это правильный подход для учета нелинейных искажений, таких как перспектива. Однако реализация такого сложного алгоритма интерполяции (например, билинейной или полиномиальной для каждого квадрата сетки) непосредственно в шаблоне сенсора Jinja2 (configuration.yaml) будет чрезвычайно сложной или даже невозможной.
Сложно, очень сложно. Пришлось поуговаривать, чтобы получить код для датчиков, преобразующих координаты (вставляется в configuration.yaml).
Скрытый текст
Не, ну должно же как-то проще решаться!
template: - sensor: - name: "Vacuum Left Px New" unit_of_measurement: "px" state: > {% set vac = state_attr('camera.x40_ultra_complete_map','vacuum_position') %} {% if vac and vac.x is number and vac.y is number %} {% set x = vac.x %} {% set y = vac.y %} {# Параметры сетки #} {% set x_min = -4579.0 %} {% set y_min = -3992.0 %} {% set dx = 1809.2 %} {% set dy = 2721.0 %} {# Коэффициенты преобразования для каждой ячейки (i,j) #} {% set T = { '0,0': {'a11': -0.009983, 'a12': -0.066817, 'b1': 832.657804, 'a21': -0.078867, 'a22': -0.007117, 'b2': 412.183573}, '0,1': {'a11': 0.019068, 'a12': -0.140582, 'b1': 990.284377, 'a21': -0.060169, 'a22': -0.054593, 'b2': 513.633816}, '0,2': {'a11': -0.002175, 'a12': -0.065921, 'b1': 874.062874, 'a21': -0.073109, 'a22': 0.006211, 'b2': 413.315245}, '0,3': {'a11': 0.002615, 'a12': -0.060233, 'b1': 870.560191, 'a21': -0.052611, 'a22': -0.000162, 'b2': 500.394952}, '0,4': {'a11': 0.012255, 'a12': -0.059423, 'b1': 884.735305, 'a21': -0.073910, 'a22': -0.001304, 'b2': 464.928882}, '1,0': {'a11': -0.003491, 'a12': -0.056227, 'b1': 890.937886, 'a21': -0.050846, 'a22': -0.000977, 'b2': 505.763572}, '1,1': {'a11': 0.015411, 'a12': -0.088180, 'b1': 952.763568, 'a21': -0.048956, 'a22': -0.005007, 'b2': 512.407395}, '1,2': {'a11': 0.000446, 'a12': -0.057291, 'b1': 854.350294, 'a21': -0.046248, 'a22': -0.002631, 'b2': 522.396612}, '1,3': {'a11': -0.009347, 'a12': -0.061078, 'b1': 851.945874, 'a21': -0.081280, 'a22': -0.002187, 'b2': 455.781485}, '1,4': {'a11': 0.012255, 'a12': -0.059423, 'b1': 884.735305, 'a21': -0.073910, 'a22': -0.001304, 'b2': 464.928882}, '2,0': {'a11': -0.005939, 'a12': -0.061828, 'b1': 871.041469, 'a21': -0.054300, 'a22': 0.005926, 'b2': 536.218771}, '2,1': {'a11': 0.006405, 'a12': -0.013656, 'b1': 865.722704, 'a21': -0.038146, 'a22': 0.004478, 'b2': 523.389948}, '2,2': {'a11': -0.027127, 'a12': -0.049086, 'b1': 798.194359, 'a21': -0.071610, 'a22': 0.001844, 'b2': 479.471436}, '2,3': {'a11': 0.000527, 'a12': -0.051492, 'b1': 820.432526, 'a21': -0.078333, 'a22': -0.003569, 'b2': 510.680259}, '2,4': {'a11': 0.017637, 'a12': -0.063504, 'b1': 894.109592, 'a21': -0.049961, 'a22': -0.003430, 'b2': 495.854427}, '3,0': {'a11': -0.002444, 'a12': -0.069982, 'b1': 835.602179, 'a21': -0.065399, 'a22': 0.031824, 'b2': 648.769444}, '3,1': {'a11': -0.010379, 'a12': -0.038164, 'b1': 876.685108, 'a21': -0.063546, 'a22': -0.032611, 'b2': 539.979253}, '3,2': {'a11': -0.028113, 'a12': -0.117443, 'b1': 1088.703, 'a21': -0.043728, 'a22': -0.018741, 'b2': 429.021}, '3,3': {'a11': 0.008957, 'a12': -0.031518, 'b1': 680.888, 'a21': -0.064406, 'a22': 0.005359, 'b2': 457.586}, '3,4': {'a11': -0.038366, 'a12': -0.073320, 'b1':1032.926937,'a21': -0.078527,'a22': -0.008437,'b2':566.662052}, '4,0': {'a11': 0.000319, 'a12': -0.060475, 'b1': 860.744277, 'a21': -0.074903, 'a22': -0.000886,'b2':562.266867}, '4,1': {'a11': -0.000857, 'a12': -0.049575,'b1':865.376888,'a21': -0.044549,'a22': -0.003059,'b2':442.610229}, '4,2': {'a11': 0.016497, 'a12': -0.004794, 'b1': 1008.000, 'a21': -0.068834, 'a22': 0.020000, 'b2': 617.500}, '4,3': {'a11': 0.004780, 'a12': -0.032272, 'b1': 687.222, 'a21': -0.051716, 'a22': 0.017072, 'b2': 351.838}, '4,4': {'a11': 0.004780, 'a12': -0.032272,'b1':687.222253,'a21': -0.051716,'a22': 0.017072,'b2':351.838141} } %} {# Вычисляем индексы ячеек и веса #} {% set i0 = ((x - x_min) / dx) | int %} {% if i0 < 0 %}{% set i0 = 0 %}{% endif %} {% if i0 > 4 %}{% set i0 = 4 %}{% endif %} {% set i1 = i0 + 1 if i0 < 4 else 4 %} {% set j0 = ((y - y_min) / dy) | int %} {% if j0 < 0 %}{% set j0 = 0 %}{% endif %} {% if j0 > 4 %}{% set j0 = 4 %}{% endif %} {% set j1 = j0 + 1 if j0 < 4 else 4 %} {% set x0 = x_min + dx * i0 %} {% set x1 = x_min + dx * i1 %} {% set y0 = y_min + dy * j0 %} {% set y1 = y_min + dy * j1 %} {% set alpha = 0 if x1 == x0 else ((x - x0) / (x1 - x0)) %} {% set beta = 0 if y1 == y0 else ((y - y0) / (y1 - y0)) %} {# Применяем 4 аффинных преобразования #} {% set k00 = i0 ~ ',' ~ j0 %} {% set k10 = i1 ~ ',' ~ j0 %} {% set k01 = i0 ~ ',' ~ j1 %} {% set k11 = i1 ~ ',' ~ j1 %} {% set t00 = T[k00] %}{% set t10 = T[k10] %} {% set t01 = T[k01] %}{% set t11 = T[k11] %} {% set px00 = t00.a11 * x + t00.a12 * y + t00.b1 %} {% set px10 = t10.a11 * x + t10.a12 * y + t10.b1 %} {% set px01 = t01.a11 * x + t01.a12 * y + t01.b1 %} {% set px11 = t11.a11 * x + t11.a12 * y + t11.b1 %} {# Билинейная интерполяция #} {% set pred_x = (1 - alpha) * (1 - beta) * px00 + alpha * (1 - beta) * px10 + (1 - alpha) * beta * px01 + alpha * beta * px11 %} {{ pred_x | round(1) }} {% else %} 0 {% endif %} - sensor: - name: "Vacuum Top Px New" unit_of_measurement: "px" state: > {% set vac = state_attr('camera.x40_ultra_complete_map','vacuum_position') %} {% if vac and vac.x is number and vac.y is number %} {% set x = vac.x %} {% set y = vac.y %} {% set x_min = -4579.0 %} {% set y_min = -3992.0 %} {% set dx = 1809.2 %} {% set dy = 2721.0 %} {% set T = { '0,0': {'a11': -0.009983, 'a12': -0.066817, 'b1': 832.657804, 'a21': -0.078867, 'a22': -0.007117, 'b2': 412.183573}, '0,1': {'a11': 0.019068, 'a12': -0.140582, 'b1': 990.284377, 'a21': -0.060169, 'a22': -0.054593, 'b2': 513.633816}, '0,2': {'a11': -0.002175, 'a12': -0.065921, 'b1': 874.062874, 'a21': -0.073109, 'a22': 0.006211, 'b2': 413.315245}, '0,3': {'a11': 0.002615, 'a12': -0.060233, 'b1': 870.560191, 'a21': -0.052611, 'a22': -0.000162, 'b2': 500.394952}, '0,4': {'a11': 0.012255, 'a12': -0.059423, 'b1': 884.735305, 'a21': -0.073910, 'a22': -0.001304, 'b2': 464.928882}, '1,0': {'a11': -0.003491, 'a12': -0.056227, 'b1': 890.937886, 'a21': -0.050846, 'a22': -0.000977, 'b2': 505.763572}, '1,1': {'a11': 0.015411, 'a12': -0.088180, 'b1': 952.763568, 'a21': -0.048956, 'a22': -0.005007, 'b2': 512.407395}, '1,2': {'a11': 0.000446, 'a12': -0.057291, 'b1': 854.350294, 'a21': -0.046248, 'a22': -0.002631, 'b2': 522.396612}, '1,3': {'a11': -0.009347, 'a12': -0.061078, 'b1': 851.945874, 'a21': -0.081280, 'a22': -0.002187, 'b2': 455.781485}, '1,4': {'a11': 0.012255, 'a12': -0.059423, 'b1': 884.735305, 'a21': -0.073910, 'a22': -0.001304, 'b2': 464.928882}, '2,0': {'a11': -0.005939, 'a12': -0.061828, 'b1': 871.041469, 'a21': -0.054300, 'a22': 0.005926, 'b2': 536.218771}, '2,1': {'a11': 0.006405, 'a12': -0.013656, 'b1': 865.722704, 'a21': -0.038146, 'a22': 0.004478, 'b2': 523.389948}, '2,2': {'a11': -0.027127, 'a12': -0.049086, 'b1': 798.194359, 'a21': -0.071610, 'a22': 0.001844, 'b2': 479.471436}, '2,3': {'a11': 0.000527, 'a12': -0.051492, 'b1': 820.432526, 'a21': -0.078333, 'a22': -0.003569, 'b2': 510.680259}, '2,4': {'a11': 0.017637, 'a12': -0.063504, 'b1': 894.109592, 'a21': -0.049961, 'a22': -0.003430, 'b2': 495.854427}, '3,0': {'a11': -0.002444, 'a12': -0.069982, 'b1': 835.602179, 'a21': -0.065399, 'a22': 0.031824, 'b2': 648.769444}, '3,1': {'a11': -0.010379, 'a12': -0.038164, 'b1': 876.685108, 'a21': -0.063546, 'a22': -0.032611, 'b2': 539.979253}, '3,2': {'a11': -0.028113, 'a12': -0.117443, 'b1': 1088.703, 'a21': -0.043728, 'a22': -0.018741, 'b2': 429.021}, '3,3': {'a11': 0.008957, 'a12': -0.031518, 'b1': 680.888, 'a21': -0.064406, 'a22': 0.005359, 'b2': 457.586}, '3,4': {'a11': -0.038366, 'a12': -0.073320, 'b1':1032.926937,'a21': -0.078527,'a22': -0.008437,'b2':566.662052}, '4,0': {'a11': 0.000319, 'a12': -0.060475, 'b1': 860.744277, 'a21': -0.074903, 'a22': -0.000886,'b2':562.266867}, '4,1': {'a11': -0.000857, 'a12': -0.049575,'b1':865.376888,'a21': -0.044549,'a22': -0.003059,'b2':442.610229}, '4,2': {'a11': 0.016497, 'a12': -0.004794, 'b1': 1008.000, 'a21': -0.068834, 'a22': 0.020000, 'b2': 617.500}, '4,3': {'a11': 0.004780, 'a12': -0.032272, 'b1': 687.222, 'a21': -0.051716, 'a22': 0.017072, 'b2': 351.838}, '4,4': {'a11': 0.004780, 'a12': -0.032272,'b1':687.222253,'a21': -0.051716,'a22': 0.017072,'b2':351.838141} } %} {# Расчёт индексов #} {% set raw_i = (x - x_min) / dx %} {% set i0 = 0 if raw_i < 0 else (4 if raw_i > 4 else raw_i|int) %} {% set raw_j = (y - y_min) / dy %} {% set j0 = 0 if raw_j < 0 else (4 if raw_j > 4 else raw_j|int) %} {% set i1 = i0 + 1 if i0 < 4 else 4 %} {% set j1 = j0 + 1 if j0 < 4 else 4 %} {% set alpha = (x - (x_min + i0*dx)) / dx %} {% set beta = (y - (y_min + j0*dy)) / dy %} {% set k00 = i0|string + ',' + j0|string %} {% set k10 = i1|string + ',' + j0|string %} {% set k01 = i0|string + ',' + j1|string %} {% set k11 = i1|string + ',' + j1|string %} {% set P00y = (T[k00]['a21'] * x) + (T[k00]['a22'] * y) + T[k00]['b2'] %} {% set P10y = (T[k10]['a21'] * x) + (T[k10]['a22'] * y) + T[k10]['b2'] %} {% set P01y = (T[k01]['a21'] * x) + (T[k01]['a22'] * y) + T[k01]['b2'] %} {% set P11y = (T[k11]['a21'] * x) + (T[k11]['a22'] * y) + T[k11]['b2'] %} {% set pred_y = (1 - alpha)*(1 - beta)*P00y + alpha*(1 - beta)*P10y + (1 - alpha)*beta*P01y + alpha*beta*P11y %} {{ pred_y | round(1) }} {% else %} 0 {% endif %}
Отклонение получилось в целом терпимое, но я не теряю надежды, что когда-нибудь все же найду более точный подход. Возможно, это позволит отказаться от накладной карточки с картой пылесоса, и указывать зону чистки прямо на плане этажа.
Итог
Ну, работает. Удобно. Каждый день используем. Ваять все это было интересно, хотя меня не отпускало чувство, что я изобретаю велосипед, и где-то есть подробные гайды, по которым все это можно сделать быстро и просто. Ткните меня в нее носом, если есть. А если не было, то теперь есть мой.
Очень рассчитываю на советы, идеи, а также на пинки за костыльные и неоптимальные решения.
