Четвертый год строю умный дом. Кажется, понял, что в нем самое главное. Не какое решение выбрать в качестве сервера, не какие технологии использовать, не каких производителей девайсы закупать — это все детали.
Главное — вовремя остановиться. И у меня это не получилось, судя по тому, что я потратил прорву времени на это.
Давно хотел сделать такую панель, но нормального гайда не нашел, пришлось придумывать собственный велосипед.
Зачем
Умный дом прекрасен и без тачпанели. Большая часть магии все равно под капотом и обходится без интерфейсов, чисто на автоматизациях. А такие команды как «включить свет» и «сделать теплее», удобно отдавать голосом через умную колонку. Или через телеграм-бот.
В общем, меня все устраивало, пока я не поставил датчики влажности почвы в цветочные горшки. Сообщение в телеграм «Полейте белый цветок на подоконнике!» пару раз ставило в тупик, так как и белых цветков и подоконников в доме немало. Я задумался, и тут все завертелось…
Идея в том, что тачпанель позволяет буквально на ходу, одним взглядом оценить обстановку в доме, запросить дополнительную информацию и отдать нужную команду просто ткнув пальцем. Новый уровень удобства.
Железо
Подробно расписывать, что такое умный дом, и как он устроен конкретно у меня, смысла не вижу, статей об этом полно. Мой основан на 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 %}
Отклонение получилось в целом терпимое, но я не теряю надежды, что когда-нибудь все же найду более точный подход. Возможно, это позволит отказаться от накладной карточки с картой пылесоса, и указывать зону чистки прямо на плане этажа.
Итог
Ну, работает. Удобно. Каждый день используем. Ваять все это было интересно, хотя меня не отпускало чувство, что я изобретаю велосипед, и где-то есть подробные гайды, по которым все это можно сделать быстро и просто. Ткните меня в нее носом, если есть. А если не было, то теперь есть мой.
Очень рассчитываю на советы, идеи, а также на пинки за костыльные и неоптимальные решения.