feat v1.11.0: full English translation + en/ru UI localization

- All card UI strings moved to src/i18n.ts (en/ru); language follows the HA
  profile automatically, new 'language: en|ru' card option forces it; GUI
  editor localized and got the language dropdown; generated device names
  localized via BuildCtx.loc.
- English-only codebase: comments, docstrings, test names, backend error
  messages and logs. Russian remains only in the ru dictionary, ru.json,
  iconFor regexes matching Russian device names (+their fixtures) and README.ru.md.
- Docs English-first: README (EN) + README.ru.md, ARCHITECTURE/DEVELOPMENT/
  ROADMAP/CHANGELOG fully translated; translations/en.json had Russian - fixed.
- Removed obsolete RELEASE_NOTES_v1.9.3.md and scripts_publish.sh.
This commit is contained in:
Matysh
2026-07-05 21:43:58 +03:00
parent f1b372f0e6
commit 94fb4af14c
29 changed files with 1692 additions and 1210 deletions
+85 -91
View File
@@ -1,163 +1,157 @@
# 🏠 House Plan — интерактивный план дома для Home Assistant # 🏠 House Plan — an interactive house plan for Home Assistant
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.** **A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
![Интерактивный план дома с устройствами](docs/images/01-plan.png) ![Interactive house plan with devices](docs/images/01-plan.png)
🇷🇺 [Документация на русском](README.ru.md)
--- ---
## 🇬🇧 English summary ## What it is and why
**House Plan** is an interactive floor-plan for Home Assistant: a custom Lovelace card + a storage integration in one HACS package. Draw rooms right on top of your floor-plan image, bind them to HA areas, and your devices appear on the plan automatically — with live states (lights, locks, covers), temperature and Zigbee signal (LQI). Everything is configured with the mouse: drag icons, zoom/pan, attach manuals (PDF) and metadata to any device, add virtual markers. Layout and configuration are stored server-side (`.storage`), shared by all users and synced live between open tabs; multi-floor, editor with grid snapping, optimistic locking. No YAML required. House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
**Install via HACS** (custom repository → `Matysh/houseplan-card`, category *Integration*), then add the *House Plan* integration in Settings → Devices & Services and put the `custom:houseplan-card` card on a dashboard. The card JS is served by the integration itself — no manual resource setup. Full documentation below is currently in Russian; open an issue if you need help in English. This is convenient when:
- you have many devices and lists are awkward to use;
- you need to grasp the state of the house "at a glance";
- you want to give access to family members — anyone can figure out a picture;
- you want a beautiful overview screen for a wall-mounted tablet.
The integration consists of two parts that are installed together:
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
--- ---
## Что это и зачем ## How it differs from alternatives
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота. A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
Это удобно, когда: | | House Plan | Typical solutions (picture-elements / ha-floorplan) |
- устройств много, и списками пользоваться неудобно;
- нужно быстро понять состояние дома «одним взглядом»;
- хочется отдать доступ близким — по картинке разберётся любой;
- вы хотите красивый обзорный экран для настенного планшета.
Интеграция состоит из двух частей, которые ставятся вместе:
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
---
## Чем отличается от аналогов
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|---|---|---| |---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода | | **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками | | **Adding devices** | Automatic, by room | You type in every entity by hand |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг | | **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG | | **Room markup** | Built-in outline editor | You draw in an external SVG editor |
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда | | **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка | | **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
Ключевые преимущества коротко: Key advantages in short:
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами. - **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане. - **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию. - **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени. - **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе. - **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
--- ---
## Установка ## Installation
### Через HACS (рекомендуется) ### Via HACS (recommended)
1. Откройте **HACS → меню (⋮) → Custom repositories**. 1. Open **HACS → menu (⋮) → Custom repositories**.
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**. 2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**. 3. Find **House Plan** in the list, install it and **restart Home Assistant**.
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**. 4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно. The card is registered automatically — no need to add a Lovelace resource manually.
### Вручную ### Manually
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant. 1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
2. Перезапустите Home Assistant. 2. Restart Home Assistant.
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**. 3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
### Добавление экрана с планом ### Adding a plan screen
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку: Create a new dashboard tab (a "Panel" view works best) and add the card:
```yaml ```yaml
type: custom:houseplan-card type: custom:houseplan-card
title: План дома title: House plan
``` ```
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране. Nothing else needs to be specified — everything else is configured right on the screen.
--- ---
## Как пользоваться ## How to use
### Шаг 1. Добавьте пространство (этаж) ### Step 1. Add a space (floor)
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство. On first open the plan is still empty — House Plan immediately offers to create the first space.
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png) ![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна. In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
![Диалог создания пространства](docs/images/03-space-dialog.png) ![Space creation dialog](docs/images/03-space-dialog.png)
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении. > 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками. Later you can add as many spaces as you like (floors, yard, garage) with the **** button next to the tabs.
### Шаг 2. Обведите комнаты ### Step 2. Outline the rooms
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке. After the first space is added, the card switches to markup mode by itself. Click grid points, connecting them with lines, and close the room outline by clicking the first point.
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant**именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**. As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area**this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png) ![Marking up a room and saving it](docs/images/05-room-dialog.png)
### Шаг 3. Устройства появляются сами ### Step 3. Devices appear by themselves
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов. As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**. By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant. From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
![Карточка устройства по клику](docs/images/08-info.png) ![Device card on click](docs/images/08-info.png)
### Шаг 4. Масштаб ### Step 4. Zoom
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства. The mouse wheel or the ** / ⊹ / ** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png) ![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png)
### Шаг 5. Расставьте значки по местам ### Step 5. Put the icons in their places
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку. Device icons can be **dragged with the mouse at any time** — no separate "edit mode" needs to be enabled. Positions are saved on the server and are identical in all browsers and devices. The **↺** button in the header restores the automatic layout.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png) ![Dragging icons — available at all times](docs/images/06-edit.png)
### Шаг 6. Добавление своих устройств вручную ### Step 6. Adding your own devices manually
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**. Not everything has to be left to the automation. With the **** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
![Добавление устройства вручную](docs/images/07-marker-dialog.png) ![Adding a device manually](docs/images/07-marker-dialog.png)
--- ---
## Удаление ## Uninstalling
1. Уберите карточку (или вкладку с планом) из дашборда. 1. Remove the card (or the tab with the plan) from the dashboard.
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции. 2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant. 3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`. 4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
--- ---
## Часто задаваемые вопросы ## Frequently asked questions
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой. **Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**. **My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок. **Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant. **Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
--- ---
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p> <p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
+157
View File
@@ -0,0 +1,157 @@
# 🏠 House Plan — интерактивный план дома для Home Assistant
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
![Интерактивный план дома с устройствами](docs/images/01-plan.png)
🇬🇧 [English documentation](README.md)
---
## Что это и зачем
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
Это удобно, когда:
- устройств много, и списками пользоваться неудобно;
- нужно быстро понять состояние дома «одним взглядом»;
- хочется отдать доступ близким — по картинке разберётся любой;
- вы хотите красивый обзорный экран для настенного планшета.
Интеграция состоит из двух частей, которые ставятся вместе:
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
---
## Чем отличается от аналогов
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
Ключевые преимущества коротко:
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
## Установка
### Через HACS (рекомендуется)
1. Откройте **HACS → меню (⋮) → Custom repositories**.
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
### Вручную
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
2. Перезапустите Home Assistant.
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
### Добавление экрана с планом
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
```yaml
type: custom:houseplan-card
title: План дома
```
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
---
## Как пользоваться
### Шаг 1. Добавьте пространство (этаж)
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
![Диалог создания пространства](docs/images/03-space-dialog.png)
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
### Шаг 2. Обведите комнаты
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
![Карточка устройства по клику](docs/images/08-info.png)
### Шаг 4. Масштаб
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png)
### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Шаг 6. Добавление своих устройств вручную
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
---
## Удаление
1. Уберите карточку (или вкладку с планом) из дашборда.
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
---
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
-9
View File
@@ -1,9 +0,0 @@
## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты)
- Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков)
вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов).
- `_lqiFor` и `_roomLqi` теперь используют общую `averageLqi` (убрано дублирование усреднения).
- Удалён мёртвый код после отказа от отдельного режима правки: поле `_edit` (писалось, но не
читалось), методы `_renderEditbar` и `_applyXY` (не вызывались).
- Поведение не изменилось — только структура и покрытие тестами. Проверено: tsc, сборка,
14 фронт-тестов + 10 бэк-тестов зелёные, headless-дымтест (рендер/зум/устройства/declump) чист.
+17 -17
View File
@@ -1,4 +1,4 @@
"""House Plan: серверная конфигурация плана дома + раздача Lovelace-карточки.""" """House Plan: server-side house plan configuration + serving the Lovelace card."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config) -> bool: async def async_setup(hass: HomeAssistant, config) -> bool:
"""Регистрируем WS-команды, HTTP-загрузку и хранилища на старте.""" """Register WS commands, the HTTP upload and the stores on startup."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY) hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY) hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
@@ -39,7 +39,7 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config entry: статика фронтенда и планов + авто-подключение JS.""" """Config entry: static frontend and plan files + auto-registration of the JS."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["entry"] = entry hass.data[DOMAIN]["entry"] = entry
entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload(entry.add_update_listener(_update_listener))
@@ -60,20 +60,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True)) static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True)) static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
await hass.http.async_register_static_paths(static_paths) await hass.http.async_register_static_paths(static_paths)
except ImportError: # старые версии HA except ImportError: # older HA versions
if card_path.exists(): if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False) hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True) hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True) hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if not card_path.exists(): if not card_path.exists():
_LOGGER.warning("houseplan-card.js не найден рядом с интеграцией: %s", card_path) _LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True return True
# Подключаем карточку. Предпочтительно — как Lovelace-ресурс (его фронтенд ДОЖИДАЕТСЯ # Register the card. Preferably as a Lovelace resource (the frontend WAITS for those
# перед рендером дашбордов, поэтому карточка доступна даже на холодном старте мобильного # before rendering dashboards, so the card is available even on a cold start of the mobile
# приложения). Если реестр ресурсов недоступен (YAML-режим Lovelace, старые версии) — # app). If the resource registry is unavailable (Lovelace YAML mode, older versions) —
# откатываемся на extra_module_url. # fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}" module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url): if not await _register_lovelace_resource(hass, module_url):
add_extra_js_url(hass, module_url) add_extra_js_url(hass, module_url)
@@ -81,10 +81,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool: async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Зарегистрировать (или обновить) карточку в реестре Lovelace-ресурсов. """Register (or update) the card in the Lovelace resource registry.
Возвращает True при успехе. Пишем идемпотентно: если ресурс с нашим путём уже есть Returns True on success. Writes idempotently: if a resource with our path already exists
обновляем URL при смене версии; отсутствует — создаём. Any-except → False (фолбэк на JS). update the URL on a version change; if absent — create it. Any-except → False (fallback to JS).
""" """
try: try:
lovelace = hass.data.get("lovelace") lovelace = hass.data.get("lovelace")
@@ -93,13 +93,13 @@ async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> b
resources = lovelace.get("resources") resources = lovelace.get("resources")
if resources is None: if resources is None:
return False return False
# реестр ресурсов должен быть загружен # the resource registry must be loaded
if hasattr(resources, "loaded") and not resources.loaded: if hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load() await resources.async_load()
resources.loaded = True resources.loaded = True
elif hasattr(resources, "async_get_info"): elif hasattr(resources, "async_get_info"):
await resources.async_get_info() await resources.async_get_info()
# только storage-режим позволяет создавать элементы # only storage mode allows creating items
if not hasattr(resources, "async_create_item"): if not hasattr(resources, "async_create_item"):
return False return False
base = FRONTEND_URL base = FRONTEND_URL
@@ -113,10 +113,10 @@ async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> b
await resources.async_update_item(item["id"], {"url": module_url}) await resources.async_update_item(item["id"], {"url": module_url})
return True return True
await resources.async_create_item({"res_type": "module", "url": module_url}) await resources.async_create_item({"res_type": "module", "url": module_url})
_LOGGER.debug("House Plan card зарегистрирована как Lovelace-ресурс: %s", module_url) _LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True return True
except Exception as err: # noqa: BLE001 — любой сбой → фолбэк except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Не удалось зарегистрировать Lovelace-ресурс (%s), фолбэк на extra_module_url", err) _LOGGER.debug("Failed to register the Lovelace resource (%s), falling back to extra_module_url", err)
return False return False
+3 -3
View File
@@ -1,4 +1,4 @@
"""Config flow: одна запись без параметров.""" """Config flow: a single entry with no parameters."""
from __future__ import annotations from __future__ import annotations
import voluptuous as vol import voluptuous as vol
@@ -9,7 +9,7 @@ from .const import CONF_ADMIN_ONLY, DOMAIN
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Установка в один шаг.""" """One-step setup."""
VERSION = 1 VERSION = 1
@@ -29,7 +29,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class HouseplanOptionsFlow(config_entries.OptionsFlow): class HouseplanOptionsFlow(config_entries.OptionsFlow):
"""Опция: правка раскладки только администраторами.""" """Option: layout editing by administrators only."""
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
if user_input is not None: if user_input is not None:
+3 -3
View File
@@ -1,4 +1,4 @@
"""Константы интеграции House Plan.""" """Constants of the House Plan integration."""
DOMAIN = "houseplan" DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout" STORAGE_KEY = f"{DOMAIN}.layout"
@@ -6,11 +6,11 @@ STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js" FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans" PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.10.0" VERSION = "1.11.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -1,7 +1,7 @@
"""HTTP-эндпоинт загрузки файлов-инструкций House Plan. """HTTP endpoint for uploading House Plan manual files.
Файлы (PDF и т.п.) грузятся не через WebSocket (у него лимит размера сообщения Files (PDF and the like) are uploaded not over WebSocket (its message size limit
большой PDF рвёт соединение), а обычным multipart POST как медиа в самом HA. breaks the connection on a large PDF) but via a plain multipart POST like media in HA itself.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,9 +12,9 @@ from aiohttp import web
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
try: # KEY_HASS — современный доступ к hass из aiohttp-приложения try: # KEY_HASS — the modern way to access hass from the aiohttp application
from homeassistant.components.http import KEY_HASS from homeassistant.components.http import KEY_HASS
except ImportError: # старые версии HA except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment] KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -33,7 +33,7 @@ _CHUNK = 64 * 1024
class HouseplanUploadView(HomeAssistantView): class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — сохранить файл маркера, вернуть URL.""" """POST /api/houseplan/upload — save a marker file, return its URL."""
url = "/api/houseplan/upload" url = "/api/houseplan/upload"
name = "api:houseplan:upload" name = "api:houseplan:upload"
@@ -59,7 +59,7 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = sanitize_marker_id(await part.text()) marker_id = sanitize_marker_id(await part.text())
elif part.name == "file": elif part.name == "file":
filename = part.filename or "file" filename = part.filename or "file"
# читаем чанками с обрывом по лимиту, а не весь файл в память # read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = [] chunks: list[bytes] = []
size = 0 size = 0
while chunk := await part.read_chunk(_CHUNK): while chunk := await part.read_chunk(_CHUNK):
@@ -72,7 +72,7 @@ class HouseplanUploadView(HomeAssistantView):
break break
blob = b"".join(chunks) blob = b"".join(chunks)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err) _LOGGER.warning("House Plan upload: multipart read error: %s", err)
return web.json_response({"error": "bad_request"}, status=400) return web.json_response({"error": "bad_request"}, status=400)
if too_large: if too_large:
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"version": "1.10.0" "version": "1.11.0"
} }
@@ -3,15 +3,15 @@
"step": { "step": {
"user": { "user": {
"title": "House Plan", "title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" } "data": { "admin_only": "Only administrators may edit the layout" }
} }
}, },
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." } "abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"data": { "admin_only": "Правка раскладки только администраторами" } "data": { "admin_only": "Only administrators may edit the layout" }
} }
} }
} }
+13 -13
View File
@@ -1,6 +1,6 @@
"""Чистая валидация и санитайзеры House Plan — без зависимостей от Home Assistant. """Pure House Plan validation and sanitizers — no Home Assistant dependencies.
Вынесено отдельно, чтобы покрывать юнит-тестами (нужен только voluptuous). Kept separate so it can be covered by unit tests (only voluptuous is needed).
""" """
from __future__ import annotations from __future__ import annotations
@@ -8,7 +8,7 @@ import re
import voluptuous as vol import voluptuous as vol
# ---------- лимиты и наборы расширений ---------- # ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024 MAX_PLAN_BYTES = 8 * 1024 * 1024
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"} FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
@@ -17,27 +17,27 @@ MAX_FILE_BYTES = 25 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$") SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# ---------- санитайзеры ---------- # ---------- sanitizers ----------
def sanitize_marker_id(value: str) -> str: def sanitize_marker_id(value: str) -> str:
"""Безопасный идентификатор маркера для имени папки. """Safe marker identifier for a folder name.
Убирает разделители путей и ведущие точки, чтобы исключить обход каталогов Strips path separators and leading dots to rule out directory traversal
(напр. '..', '../x'); пустой/из одних точек результат 'misc'. (e.g. '..', '../x'); an empty/dots-only result 'misc'.
""" """
cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64] cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64]
return cleaned or "misc" return cleaned or "misc"
def sanitize_filename(value: str) -> str: def sanitize_filename(value: str) -> str:
"""Отбросить путь и ведущие точки, оставить безопасное имя файла.""" """Drop the path and leading dots, keep a safe file name."""
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file" return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
def file_ext(filename: str) -> str: def file_ext(filename: str) -> str:
"""Расширение файла в нижнем регистре ('' если нет).""" """Lowercase file extension ('' if none)."""
raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return raw.rsplit(".", 1)[-1].lower() if "." in raw else "" return raw.rsplit(".", 1)[-1].lower() if "." in raw else ""
@@ -46,10 +46,10 @@ def valid_space_id(value: str) -> bool:
return bool(SPACE_ID_RE.match(value)) return bool(SPACE_ID_RE.match(value))
# ---------- voluptuous-схемы ---------- # ---------- voluptuous schemas ----------
POS_SCHEMA = vol.Schema( POS_SCHEMA = vol.Schema(
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)}, {vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
extra=vol.ALLOW_EXTRA, # v2-записи несут ключ "s" (space id) extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
) )
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA}) LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
@@ -59,7 +59,7 @@ POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict: def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")): if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
return room return room
raise vol.Invalid("room: нужен poly или x/y/w/h") raise vol.Invalid("room: poly or x/y/w/h is required")
ROOM_SCHEMA = vol.All( ROOM_SCHEMA = vol.All(
@@ -115,5 +115,5 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("markers", default=list): [MARKER_SCHEMA], vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
}, },
extra=vol.ALLOW_EXTRA, # неизвестные ключи (легаси) не ломают загрузку extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
) )
+28 -28
View File
@@ -1,4 +1,4 @@
"""WS-команды House Plan: раскладка, конфигурация пространств, загрузка планов.""" """House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -24,7 +24,7 @@ from .validation import (
@callback @callback
def async_register(hass: HomeAssistant) -> None: def async_register(hass: HomeAssistant) -> None:
"""Регистрация WS-команд.""" """Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get) websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_layout_set) websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_layout_update) websocket_api.async_register_command(hass, ws_layout_update)
@@ -43,10 +43,10 @@ def _config_store(hass: HomeAssistant):
def _write_lock(hass: HomeAssistant) -> asyncio.Lock: def _write_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Единый лок на цикл load→modify→save обоих хранилищ. """A single lock over the load→modify→save cycle of both stores.
Без него параллельные WS-вызовы теряют изменения (last-writer-wins), Without it, parallel WS calls lose changes (last-writer-wins),
а проверка expected_rev неатомарна. and the expected_rev check is not atomic.
""" """
return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock()) return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock())
@@ -57,13 +57,13 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
return connection.user.is_admin if admin_only else True return connection.user.is_admin if admin_only else True
# ---------------- раскладка ---------------- # ---------------- layout ----------------
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"}) @websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response @websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Вернуть сохранённую раскладку.""" """Return the saved layout."""
data = await _store(hass).async_load() or {} data = await _store(hass).async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})}) connection.send_result(msg["id"], {"layout": data.get("layout", {})})
@@ -73,9 +73,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Полностью заменить раскладку.""" """Replace the layout entirely."""
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
async with _write_lock(hass): async with _write_lock(hass):
await _store(hass).async_save({"layout": msg["layout"]}) await _store(hass).async_save({"layout": msg["layout"]})
@@ -91,9 +91,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Обновить позицию одного устройства.""" """Update the position of a single device."""
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
store = _store(hass) store = _store(hass)
async with _write_lock(hass): async with _write_lock(hass):
@@ -112,9 +112,9 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Удалить позицию одного устройства (чистка при удалении маркера).""" """Delete the position of a single device (cleanup when a marker is removed)."""
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
store = _store(hass) store = _store(hass)
async with _write_lock(hass): async with _write_lock(hass):
@@ -126,13 +126,13 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True}) connection.send_result(msg["id"], {"ok": True})
# ---------------- конфигурация пространств ---------------- # ---------------- space configuration ----------------
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"}) @websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
@websocket_api.async_response @websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Вернуть конфигурацию и её ревизию.""" """Return the configuration and its revision."""
data = await _config_store(hass).async_load() or {} data = await _config_store(hass).async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})} config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)}) connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
@@ -147,14 +147,14 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Заменить конфигурацию с оптимистичной блокировкой (expected_rev). """Replace the configuration with optimistic locking (expected_rev).
Защита от гонки нескольких открытых клиентов: если конфиг менялся с момента Protects against races between several open clients: if the config has changed since
последнего чтения клиентом возвращается ошибка conflict, клиент обязан the client's last read — a conflict error is returned, and the client must
перечитать конфиг и повторить правку поверх свежей версии. re-read the config and re-apply its edit on top of the fresh version.
""" """
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration")
return return
store = _config_store(hass) store = _config_store(hass)
async with _write_lock(hass): async with _write_lock(hass):
@@ -163,7 +163,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if "expected_rev" in msg and msg["expected_rev"] != current_rev: if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error( connection.send_error(
msg["id"], "conflict", msg["id"], "conflict",
f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})", f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
) )
return return
new_rev = current_rev + 1 new_rev = current_rev + 1
@@ -172,7 +172,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- загрузка планов ---------------- # ---------------- plan uploads ----------------
@websocket_api.websocket_command( @websocket_api.websocket_command(
@@ -185,21 +185,21 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Сохранить файл плана пространства; вернуть URL для карточки.""" """Save a space plan file; return the URL for the card."""
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Загрузка планов разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Only administrators may upload plans")
return return
space_id = msg["space_id"] space_id = msg["space_id"]
if not valid_space_id(space_id): if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: только [a-z0-9_-], до 64 символов") connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return return
try: try:
raw = base64.b64decode(msg["data"], validate=True) raw = base64.b64decode(msg["data"], validate=True)
except (binascii.Error, ValueError): except (binascii.Error, ValueError):
connection.send_error(msg["id"], "invalid_data", "data должен быть корректным base64") connection.send_error(msg["id"], "invalid_data", "data must be valid base64")
return return
if len(raw) > MAX_PLAN_BYTES: if len(raw) > MAX_PLAN_BYTES:
connection.send_error(msg["id"], "too_large", f"План больше {MAX_PLAN_BYTES // 1024 // 1024} МБ") connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
return return
plans_dir = Path(hass.config.path(PLANS_DIR)) plans_dir = Path(hass.config.path(PLANS_DIR))
@@ -207,7 +207,7 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
def _write() -> int: def _write() -> int:
plans_dir.mkdir(parents=True, exist_ok=True) plans_dir.mkdir(parents=True, exist_ok=True)
# убрать старые варианты с другим расширением # remove old variants with a different extension
for old_ext in PLAN_EXTENSIONS: for old_ext in PLAN_EXTENSIONS:
old = plans_dir / f"{space_id}.{old_ext}" old = plans_dir / f"{space_id}.{old_ext}"
if old_ext != msg["ext"] and old.exists(): if old_ext != msg["ext"] and old.exists():
+126 -126
View File
File diff suppressed because one or more lines are too long
+89 -89
View File
@@ -1,101 +1,101 @@
# Архитектура House Plan # House Plan architecture
Обновлено: 2026-07-04 (v1.2.2). Репозиторий = HACS-интеграция (категория **Integration**), Updated: 2026-07-04 (v1.2.2). The repository = a HACS integration (category **Integration**)
которая содержит и бэкенд (`custom_components/houseplan`), и Lovelace-карточку (`src/``dist/`). that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/``dist/`).
## Состав ## Layout
``` ```
houseplan-card/ houseplan-card/
├─ src/ # исходники карточки (TypeScript + Lit 3) ├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # карточка: рендер, состояния, drag, tooltip, sticky-шапка │ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ editor.ts # GUI-редактор конфига (ha-form + селекторы) │ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # правила иконок (iconFor), курирование, группы, приоритет доменов │ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
│ └─ data/ │ └─ data/
│ ├─ house.ts # геометрия: ROOMS (комнаты→area), FLOOR_VB (viewBox), названия │ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # ВЕКТОРНЫЕ планы (SVG base64) + FLOOR_BG_RECT (позиционирование) │ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
├─ dist/houseplan-card.js # сборка (rollup+terser), ~290 КБ, планы внутри ├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ custom_components/houseplan/ # интеграция HA ├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS-команды, раздача JS (add_extra_js_url) │ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ websocket_api.py # houseplan/layout/get|set|update │ ├─ websocket_api.py # houseplan/layout/get|set|update
│ ├─ config_flow.py # одна запись; опция admin_only (правка только админам) │ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL │ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
│ └─ frontend/houseplan-card.js # копия dist, раздаётся как /houseplan_files/houseplan-card.js │ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
├─ assets/ # исходники планов: f1_plan.svg, f2_plan.svg (РЕМПЛАННЕР), *_bg.png (старые растровые) ├─ assets/ # plan sources: f1_plan.svg, f2_plan.svg (REMPLANNER), *_bg.png (old raster versions)
├─ hacs.json # манифест HACS ├─ hacs.json # HACS manifest
└─ docs/ # эта документация └─ docs/ # this documentation
``` ```
## Ключевые решения ## Key decisions
1. **Один репозиторий — интеграция + карточка.** Интеграция раздаёт JS 1. **One repository — integration + card.** The integration serves the JS
(`async_register_static_paths`) и регистрирует его как **Lovelace-ресурс** (module) — (`async_register_static_paths`) and registers it as a **Lovelace resource** (module) —
фронтенд дожидается ресурсов перед рендером, поэтому карточка работает и на холодном старте the frontend waits for resources before rendering, so the card also works on a cold start
мобильного приложения (в отличие от `add_extra_js_url`, который остаётся фолбэком для of the mobile app (unlike `add_extra_js_url`, which remains a fallback for
YAML-режима). Пользователю не нужно вручную добавлять ресурс. YAML mode). The user does not need to add the resource manually.
2. **Раскладка иконок — на сервере.** `helpers.storage.Store(1, "houseplan.layout")` 2. **Icon layout lives on the server.** `helpers.storage.Store(1, "houseplan.layout")`
`.storage/houseplan.layout`. Карточка читает/пишет через `hass.callWS` `.storage/houseplan.layout`. The card reads/writes via `hass.callWS`
(`houseplan/layout/get|set|update`). Fallback — localStorage (если интеграции нет). (`houseplan/layout/get|set|update`). Fallback — localStorage (when the integration is absent).
3. **Без токена.** Всё из объекта `hass` фронтенда: `hass.states` (реактивно), 3. **No token.** Everything comes from the frontend `hass` object: `hass.states` (reactive),
`hass.devices/entities/areas` (реестры). Никаких прямых WS-подключений. `hass.devices/entities/areas` (registries). No direct WS connections.
4. **Реактивность.** Каждое изменение состояния в HA приводит к set hass → re-render. 4. **Reactivity.** Every state change in HA leads to set hass → re-render.
Температуры/LQI/вкл-выкл live по определению (проверено подменой state). Temperatures/LQI/on-off are live by definition (verified by substituting state).
## Координатная система ## Coordinate system
- Базовое пространство: **1489×1053** («пиксели» старого PNG-рендера, 1 ед. = 1 px). - Base space: **1489×1053** ("pixels" of the old PNG render, 1 unit = 1 px).
Все комнаты, позиции иконок и viewBox этажей — в нём. НЕ менять без миграции раскладки. All rooms, icon positions and floor viewBoxes live in it. DO NOT change without a layout migration.
- Векторные планы вставляются `<image href=svg>` в прямоугольник `FLOOR_BG_RECT`: - Vector plans are inserted as `<image href=svg>` into the `FLOOR_BG_RECT` rectangle:
- f1: scale **0.647**, offset **(490, 27)** → rect [490, 27, 774.2, 949.3] - f1: scale **0.647**, offset **(490, 27)** → rect [490, 27, 774.2, 949.3]
- f2: scale **0.896**, offset **(351, 21)** → rect [351, 21, 1048.4, 961.4] - f2: scale **0.896**, offset **(351, 21)** → rect [351, 21, 1048.4, 961.4]
- вычислено растровой корреляцией (cv2.matchTemplate по бинаризованным картам темноты) - computed via raster correlation (cv2.matchTemplate on binarized darkness maps) of the
рендера SVG с эталонным PNG; точность ~1 px. Скрипты воспроизводимы (docs/DEVELOPMENT.md). SVG render against the reference PNG; accuracy ~1 px. The scripts are reproducible (docs/DEVELOPMENT.md).
- Комнаты (`ROOMS`) сняппены к внутренним граням стен (полуавто: поиск ближайшей «тёмной линии» - Rooms (`ROOMS`) are snapped to the inner faces of walls (semi-automatic: search for the nearest
по профилю + ручная доводка по overlay-рендерам). "dark line" along the profile + manual fine-tuning against overlay renders).
## Модель данных карточки (runtime) ## Card data model (runtime)
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (сущность для `DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
more-info по приоритету доменов), temp, members[] (группа ламп), link/linkPrimary (Z2M-группа). more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
Построение из реестров (`_buildDevices`), правила 1-в-1 из прототипа: Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
- показываются только устройства с area из списка комнат; - only devices with an area from the room list are shown;
- скрываются: entry_type=service, интеграции из EXCLUDED_DOMAINS, model=Group, сцены, мосты, - hidden: entry_type=service, integrations from EXCLUDED_DOMAINS, model=Group, scenes, bridges,
под-устройства myheat, дубли по «имя|area»; myheat sub-devices, duplicates by "name|area";
- **устройство с сущностью `lock.*` всегда получает `mdi:lock`** (TTLock-замки в реестре - **a device with a `lock.*` entity always gets `mdi:lock`** (TTLock locks in the registry
называются «Дом»/«Терраса»/«Кладовка» — по имени не распознать); are named "Dom"/"Terrasa"/"Kladovka" [House/Terrace/Storeroom] — unrecognizable by name);
- лампы (mdi:lightbulb) ≥2 в комнате схлопываются в группу `mdi:lightbulb-group` - lamps (mdi:lightbulb) with ≥2 in a room collapse into a group `mdi:lightbulb-group`
(клик → меню: вся группа + отдельные). (click → menu: the whole group + individual lamps).
## Живые данные ## Live data
- Температура: сущность с device_class=temperature / °C / `_temperature$`метка справа. - Temperature: an entity with device_class=temperature / °C / `_temperature$`label on the right.
- LQI (zigbee): среднее по `*_linkquality`-сущностям → метка под иконкой; цвет - LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via
`lqiColor()`: ≤40 красный → ≥180 зелёный (hsl-градиент). Среднее по комнате — в тултипе комнаты. `lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip.
- Классы состояния иконки: on (жёлтый), open (оранжевый: cover/valve/lock/binary_sensor - Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
проблемных классов), unavail (прозрачность). of problem classes), unavail (transparency).
## Размеры ## Sizes
`icon_size` в конфиге = **% ширины видимой области плана** (дефолт 2.5). Реализация: `icon_size` in the config = **% of the visible plan area width** (default 2.5). Implementation:
`.stage { container-type: inline-size }` + размеры в `cqw`. Легаси px-значения (>8) игнорируются. `.stage { container-type: inline-size }` + sizes in `cqw`. Legacy px values (>8) are ignored.
## Sticky-шапка ## Sticky header
`.head { position: sticky; top: var(--header-height, 56px) }`; ОБЯЗАТЕЛЬНО `.head { position: sticky; top: var(--header-height, 56px) }`; it is MANDATORY that
`ha-card { overflow: visible }``overflow: hidden` ломает sticky. `ha-card { overflow: visible }``overflow: hidden` breaks sticky.
## Маркеры устройств (v1.6.0+) ## Device markers (v1.6.0+)
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?, `config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. Гибрид: авто-устройства HA name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
появляются сами; маркер с `binding=device:<id>` их перекрывает (метаданные/перепривязка/скрытие), appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>`для групп/хелперов, `virtual` — ручной значок без HA. id маркера = device_id / `entity:<eid>`for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (сохраняет позицию в layout). Пикер привязки исключает уже размещённые `lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
ссылки и дубли по имя|area. Файлы-инструкции: `houseplan/file/set``/config/houseplan/files/<id>/`, references and duplicates by name|area. Manual files: `houseplan/file/set``/config/houseplan/files/<id>/`,
отдача `/houseplan_files/files/`. served from `/houseplan_files/files/`.
## Серверная конфигурация (v1.3.0+) ## Server-side configuration (v1.3.0+)
`.storage/houseplan.config` (Store): `.storage/houseplan.config` (Store):
```json ```json
@@ -104,31 +104,31 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. Гибрид: а
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}], "virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
"settings": {"exclude_integrations":[],"group_lights":true} } "settings": {"exclude_integrations":[],"group_lights":true} }
``` ```
Все координаты **нормированные (0..1 от плана пространства)**; рендер-пространство All coordinates are **normalized (0..1 of the space plan)**; the render space is
1000 × 1000/aspect. Раскладка v2: `{device_id: {"s": space, "x", "y"}}` (нормир.). 1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
Файлы планов: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`. Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
Если server config пуст — карточка работает от legacy-бандла (дача) и показывает кнопку If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
миграции «На сервер» в режиме правки. Дача мигрирована 2026-07-04. "To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
## Редактор разметки (v1.4.0+) ## Markup editor (v1.4.0+)
Состояние в карточке: `_markup` (режим), `_tool` (draw/erase/delroom), `_path` (текущий контур, State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
вершины по сетке GRID_N=60). Клики по stage → `_svgPoint``_snap`. Каждая пара точек добавляет vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint``_snap`. Each pair of points adds a
сегмент в `space.segments` (дедуп по ключу, сохранение config/set с дебаунсом). Контур замкнут segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
= клик по первой вершине → селект зоны (hass.areas) + имя → room {poly}. Комнаты-полигоны и = a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
прямоугольники рендерятся единообразно (hit-test: point-in-polygon / rect). rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
## WS API интеграции ## Integration WS API
| Команда | Параметры | Ответ | | Command | Parameters | Response |
|---|---|---| |---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` | | `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only опционально) | | `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` | | `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/config/get` | — | `{config, rev}` | | `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; событие `houseplan_config_updated` | | `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8МБ) | `{ok, url}` | | `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, лимит WS) | | `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**Загрузка файлов — HTTP** (не WS, у него лимит размера сообщения): `POST /api/houseplan/upload` **File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Отдача — `/houseplan_files/files/`. (multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
+281 -263
View File
@@ -1,310 +1,328 @@
# Changelog # Changelog
## v1.10.0 — 2026-07-05 (аудит: гонки записи, XSS, модульность) ## v1.11.0 — 2026-07-05 (full English translation + UI localization)
**Бэкенд** - **UI localization (en/ru)**: every card string moved to `src/i18n.ts` dictionaries.
- **Гонка записи устранена**: все циклы load→modify→save (`layout/set|update|delete`, The language follows the HA user profile (`hass.locale`) automatically; a new
`config/set`) сериализованы общим `asyncio.Lock` — параллельные WS-вызовы больше не теряют `language: en|ru` card option forces it. The GUI editor got the option and its own
изменения, проверка `expected_rev` стала атомарной. localized labels. Generated device names (light group, unnamed, virtual device)
- Новая WS-команда `houseplan/layout/delete` — чистка позиции при удалении маркера are localized via a `loc` callback in `BuildCtx`.
(раньше в `.storage/houseplan.layout` копились сироты). - **English-only codebase**: all comments, docstrings, section banners, JSDoc, test
- Удалена мёртвая WS-команда `houseplan/file/set` — файлы грузятся только по HTTP names, backend WS/HTTP error messages and log lines translated to English.
(`/api/houseplan/upload`), как и было в карточке. Russian remains only where it is functional or content: the `ru` i18n dictionary,
- HTTP-загрузка: лимит 25 МБ применяется ПО МЕРЕ чтения multipart (обрыв на лимите), `translations/ru.json` (config flow), `iconFor` regex patterns matching Russian
а не после чтения всего файла в память; `stat()` файлов ушёл в executor. device names (with their test fixtures), and the Russian documentation copy.
- `request.app[KEY_HASS]` вместо устаревшего `request.app["hass"]` (с фолбэком для старых HA). - **Docs**: README is now English-first with a full Russian copy in `README.ru.md`;
`docs/ARCHITECTURE.md`, `DEVELOPMENT.md`, `ROADMAP.md` and the entire CHANGELOG
history translated to English. `translations/en.json` had Russian strings — fixed.
- Removed obsolete `RELEASE_NOTES_v1.9.3.md` and `scripts_publish.sh` (publication
is done; the repo lives at github.com/Matysh/houseplan-card).
**Фронтенд** ## v1.10.0 — 2026-07-05 (audit: write races, XSS, modularity)
- **God-component разобран**: `houseplan-card.ts` 3023 → ~1990 строк. **Backend**
Стили → `styles.ts`; типы → `types.ts`; построение устройств из реестров HA - **Write race eliminated**: all load→modify→save cycles (`layout/set|update|delete`,
(курирование, группы света, маркеры, LQI/температура) → `devices.ts` (чистые функции). `config/set`) are serialized by a shared `asyncio.Lock` — concurrent WS calls no longer lose
- **Last-writer-wins в раскладке устранён**: `_saveMarker` пишет позицию точечно changes, and the `expected_rev` check became atomic.
(`layout/update`), а не всей раскладкой (`layout/set`) — не затирает позиции из других - New WS command `houseplan/layout/delete` — cleans up a position when a marker is deleted
окон (регресс-класс инцидента v1.4.4). Перепривязка/удаление подчищают старую позицию. (previously orphans accumulated in `.storage/houseplan.layout`).
- **XSS закрыт**: `link` и `pdfs[].url` маркеров проходят `safeUrl()` (только http(s) - Removed the dead WS command `houseplan/file/set` — files are uploaded over HTTP only
и относительные пути; `javascript:`/`data:` отбрасываются). +12 тестов. (`/api/houseplan/upload`), as the card already did.
- Загрузка файлов через `hass.fetchWithAuth` (автообновление протухшего токена), - HTTP upload: the 25 MB limit is enforced WHILE reading the multipart stream (abort at the limit),
фолбэк на сырой токен. not after reading the whole file into memory; file `stat()` moved into the executor.
- **Хардкод дачи убран из GUI-редактора**: список «пространство по умолчанию» строится - `request.app[KEY_HASS]` instead of the deprecated `request.app["hass"]` (with a fallback for older HA).
из серверного конфига (WS `config/get`), а не зашитых f1/f2/yard.
- `rules.ts`: удалён мёртвый экспорт `GROUP_TITLES`; регэкспы `iconFor` прекомпилированы.
- Единый `_errText()` во всех обработчиках ошибок (никогда «[object Object]»).
## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты) **Frontend**
- Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков) - **The god-component was broken up**: `houseplan-card.ts` 3023 → ~1990 lines.
вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов). Styles → `styles.ts`; types → `types.ts`; building devices from the HA registries
- `_lqiFor` и `_roomLqi` теперь используют общую `averageLqi` (убрано дублирование усреднения). (curation, light groups, markers, LQI/temperature) → `devices.ts` (pure functions).
- Удалён мёртвый код после отказа от отдельного режима правки: поле `_edit` (писалось, но не - **Last-writer-wins in the layout eliminated**: `_saveMarker` writes the position pointwise
читалось), методы `_renderEditbar` и `_applyXY` (не вызывались). (`layout/update`), not the whole layout (`layout/set`) — it no longer wipes positions from other
- Поведение не изменилось — только структура и покрытие тестами. Проверено: tsc, сборка, windows (the regression class of the v1.4.4 incident). Rebinding/deletion clean up the old position.
14 фронт-тестов + 10 бэк-тестов зелёные, headless-дымтест (рендер/зум/устройства/declump) чист. - **XSS closed**: marker `link` and `pdfs[].url` go through `safeUrl()` (only http(s)
and relative paths; `javascript:`/`data:` are rejected). +12 tests.
- File uploads via `hass.fetchWithAuth` (auto-refresh of an expired token),
fallback to the raw token.
- **The dacha hardcode removed from the GUI editor**: the "default space" list is built
from the server config (WS `config/get`), not the baked-in f1/f2/yard.
- `rules.ts`: removed the dead export `GROUP_TITLES`; the `iconFor` regexes are precompiled.
- A single `_errText()` in all error handlers (never "[object Object]").
## v1.9.2 — 2026-07-05 (сетка вдвое мельче) ## v1.9.3 — 2026-07-05 (audit + refactoring + tests)
- Шаг сетки разметки/привязки уменьшен вдвое: `GRID_N` 120 → 240. Позиции устройств и контуры - The pure functions `fitView` (viewport contain rectangle) and `declump` (icon push-apart)
комнат НЕ пересчитываются и не сдвигаются: координаты хранятся нормированными долями и снапятся were extracted from the card into `logic.ts` — covered by unit tests (was 9 → now 14 tests).
к узлам сетки, а старые узлы (кратные 1/120) — точное подмножество новых (кратных 1/240). - `_lqiFor` and `_roomLqi` now use the shared `averageLqi` (duplicate averaging removed).
Проверено на живой раскладке (65 позиций — все уже на новой сетке). Перетаскивание теперь - Removed dead code left after dropping the separate edit mode: the `_edit` field (written but
позволяет более точное позиционирование. never read), the methods `_renderEditbar` and `_applyXY` (never called).
- No behavior change — only structure and test coverage. Verified: tsc, build,
14 frontend tests + 10 backend tests green, headless smoke test (render/zoom/devices/declump) clean.
## v1.9.1 — 2026-07-05 (сигнал Zigbee: чтение из атрибута + ZHA) ## v1.9.2 — 2026-07-05 (grid twice as fine)
- Уровень сигнала теперь берётся не только из выделенного сенсора `*_linkquality` (Z2M), но и: - The markup/snapping grid step was halved: `GRID_N` 120 → 240. Device positions and room
из сенсоров `*_lqi` (ZHA), и из АТРИБУТА `linkquality`/`lqi` на любой сущности устройства. outlines are NOT recomputed and do not shift: coordinates are stored as normalized fractions and snap
Это покрывает устройства, у которых отдельный сенсор сигнала отключён, но значение есть в атрибуте. to grid nodes, and the old nodes (multiples of 1/120) are an exact subset of the new ones (multiples of 1/240).
- ПРИМЕЧАНИЕ: если у устройства сенсор `*_linkquality` отключён (disabled_by=integration) И значение Verified on the live layout (65 positions — all already on the new grid). Dragging now
нигде не публикуется как атрибут — сигнал показать нельзя, нужно включить сенсор в реестре HA. allows more precise positioning.
## v1.9.0 — 2026-07-05 (UX: перетаскивание всегда, расталкивание, показать все, адаптив) ## v1.9.1 — 2026-07-05 (Zigbee signal: reading from the attribute + ZHA)
- **Перетаскивание значков доступно всегда** — отдельный «режим правки» убран (кнопка ✥ удалена). - The signal level is now taken not only from the dedicated `*_linkquality` sensor (Z2M), but also:
Клик по значку открывает карточку устройства (правка метаданных — через кнопку «Редактировать» from `*_lqi` sensors (ZHA), and from the `linkquality`/`lqi` ATTRIBUTE on any entity of the device.
в ней), перетаскивание работает в любой момент. `_pointerDown` больше не требует `_edit`. This covers devices whose dedicated signal sensor is disabled but the value is available in an attribute.
- **Значки не скучиваются** (`_declump`): после авто-раскладки по сетке выполняется расталкивание - NOTE: if a device's `*_linkquality` sensor is disabled (disabled_by=integration) AND the value
в пределах комнаты, чтобы иконки не налезали друг на друга. is not published anywhere as an attribute — the signal cannot be shown; enable the sensor in the HA registry.
- **Позиции фиксируются при сохранении комнаты**: авто-позиции устройств зоны один раз пишутся
в раскладку, поэтому при смене порядка в реестре HA значки не перетасовываются.
- **Дубли не скрываются, а нумеруются**: устройства с одинаковым «имя|зона» получают суффикс
(« 2», « 3»…) вместо тихого исчезновения.
- **Кнопка 👁 «Показать все устройства»** в шапке: временно отключает курирование и показывает
все устройства зоны (мосты, служебные, дубли) — на случай, если нужное скрыто. Флаг `show_all`
в настройках конфига.
- **Подсказка продолжения разметки**: после сохранения комнаты тост показывает счётчик комнат и
предлагает обвести следующую или выйти из разметки.
- **Адаптивная шапка**: тулбар переносится и ужимается на узких экранах (media-query ≤620px).
## v1.8.4 — 2026-07-04 (онбординг: обязательные поля + ведение пользователя) ## v1.9.0 — 2026-07-05 (UX: always-on dragging, declumping, show all, responsive)
- Первичная инициализация приведена к целевому сценарию: установка → диалог пространства → - **Icon dragging is available at all times** — the separate "edit mode" is gone (the ✥ button removed).
комнаты → авто-значки устройств. Clicking an icon opens the device card (metadata editing — via the "Edit" button
- **Подложка обязательна** при создании пространства: «Сохранить» неактивна без загруженного inside it), dragging works at any moment. `_pointerDown` no longer requires `_edit`.
плана (раньше требовалось только название — можно было создать пустое пространство). - **Icons no longer clump together** (`_declump`): after the automatic grid layout a push-apart pass
- **Зона комнаты обязательна** с явным выходом: основная «Сохранить» требует привязку к зоне HA; runs within the room so that icons do not overlap each other.
для декоративных комнат без устройств (холл, сауна) есть отдельная кнопка «Без зоны» - **Positions are pinned when a room is saved**: the auto-positions of the area's devices are written
(нужно только имя). Раньше сохранить можно было по «имя ИЛИ зона». into the layout once, so icons do not get reshuffled when the order in the HA registry changes.
- **Ведение пользователя:** на пустом серверном конфиге диалог пространства открывается - **Duplicates are numbered instead of hidden**: devices with the same "name|area" get a suffix
автоматически; после добавления первого пространства карточка сама входит в режим разметки (" 2", " 3"…) instead of silently disappearing.
комнат с подсказкой «обведите комнаты». - **The 👁 "Show all devices" button** in the header: temporarily disables curation and shows
- При сохранении комнаты с зоной значки устройств этой зоны авто-расставляются внутри контура all devices of the area (bridges, service records, duplicates) — in case a needed one is hidden. The `show_all`
(курируемый набор: без мостов/служебных/дублей/одиночных ламп из групп), тост показывает, flag in the config settings.
сколько устройств добавлено. - **Markup continuation hint**: after saving a room a toast shows the room counter and
suggests outlining the next one or leaving markup mode.
- **Responsive header**: the toolbar wraps and shrinks on narrow screens (media query ≤620px).
## v1.8.3 — 2026-07-04 (мгновенный старт: кэш конфига, данные в фоне) ## v1.8.4 — 2026-07-04 (onboarding: required fields + user guidance)
- Иконки и план появлялись с задержкой — карточка ждала ответа сервера (WS `config/get` - Initial setup brought to the target scenario: install → space dialog →
+ ретраи прогрева соединения), до этого модель пространств была пуста. rooms → auto device icons.
- Внедрён кэш «stale-while-revalidate»: снимок серверного конфига + rev + раскладки - **The background is mandatory** when creating a space: "Save" stays disabled without an uploaded
сохраняется в `localStorage['houseplan_card_cfg_v1']`. В `setConfig` (синхронно, до прихода plan (previously only the name was required — an empty space could be created).
hass) он восстанавливается — план и значки рисуются сразу из кэша (проверено: setConfig - **The room area is mandatory**, with an explicit escape hatch: the main "Save" requires binding to an HA area;
наполняет модель за 0.2 мс без сервера), а свежие данные догружаются в фоне и обновляют for decorative rooms without devices (hall, sauna) there is a separate "No area" button
карточку. Живые состояния/температуры/сигнал подтягиваются по мере прогрева hass. (only a name is needed). Previously saving was possible with "name OR area".
- Кэш обновляется после каждой успешной загрузки, live-ресинка и сохранения позиций иконок, - **User guidance:** with an empty server config the space dialog opens
поэтому при следующем открытии виден актуальный снимок. При недоступном сервере показывается automatically; after the first space is added the card enters room markup mode
последний известный план вместо пустого онбординга. by itself with an "outline the rooms" hint.
- When a room with an area is saved, the icons of that area's devices are auto-placed inside the outline
(the curated set: without bridges/service records/duplicates/individual lamps from groups), and a toast shows
how many devices were added.
## v1.8.2 — 2026-07-04 (векторно-чёткий зум через viewBox + полная ширина сцены) ## v1.8.3 — 2026-07-04 (instant start: config cache, data in the background)
- Зум переведён с CSS `transform: scale()` на манипуляцию `viewBox` SVG. Раньше слой - Icons and the plan appeared with a delay — the card waited for the server response (WS `config/get`
растрировался и потом масштабировался — при приближении всё «мылилось». Теперь браузер + connection warm-up retries), and until then the space model was empty.
перерисовывает вектор в целевом разрешении: иконки, подписи комнат, линии, штриховка и - A "stale-while-revalidate" cache was introduced: a snapshot of the server config + rev + layout
векторная подложка остаются чёткими на любом масштабе (проверено на 274%). is saved in `localStorage['houseplan_card_cfg_v1']`. In `setConfig` (synchronously, before
- Слой иконок (HTML) позиционируется и масштабируется по тому же `view`, а не общим hass arrives) it is restored — the plan and icons are drawn immediately from the cache (verified: setConfig
transform — иконки чёткие и растут вместе с планом. populates the model in 0.2 ms without the server), while fresh data loads in the background and updates the
- Сцена теперь занимает всю ширину вьюпорта (модель «contain» по аспекту сцены): при card. Live states/temperatures/signal come in as hass warms up.
зум-ине контент заполняет ширину без чёрных полей по бокам. При полном отдалении виден - The cache is refreshed after every successful load, live resync and icon position save,
весь план (по ширине и высоте), поля вокруг залиты фоном карточки, а не чёрным. so on the next open the current snapshot is visible. When the server is unavailable the
- Вся координатная математика (клик разметки `_svgPoint`, drag иконок, pan, pinch) переписана last known plan is shown instead of the empty onboarding.
в единых `view`-координатах — редактирование корректно на любом зуме. ResizeObserver сцены
пересчитывает `view` при изменении размеров (сайдбар, поворот). Пан/зум ограничены `fit`.
## v1.8.1 — 2026-07-04 (fit-to-viewport + сохранение зума по пространствам) ## v1.8.2 — 2026-07-04 (vector-crisp zoom via viewBox + full-width stage)
- Уменьшение масштаба до полного вида: нижний предел зума = 100% совпадает с полностью - Zoom moved from CSS `transform: scale()` to manipulating the SVG `viewBox`. Previously the layer
видимым планом. `.stage` теперь ограничена по высоте вьюпорта was rasterized and then scaled — everything "blurred" when zooming in. Now the browser
(`width:min(100%, calc((100dvh 118px) × aspect))`, `max-width:100%`, центрирование), redraws the vector at the target resolution: icons, room labels, lines, hatching and the
поэтому при сбросе/зум-ауте вся схема видна и по ширине, и по высоте без обрезки. vector background remain crisp at any scale (verified at 274%).
- Масштаб запоминается для каждого пространства отдельно и локально на устройстве - The icon layer (HTML) is positioned and scaled by the same `view`, not a global
(`localStorage['houseplan_card_zoom_v1']`, `_zoomBySpace`): переключение этажей transform — the icons are crisp and grow together with the plan.
восстанавливает свой зум; при возврате пан центрируется и ограничивается. - The stage now occupies the full viewport width (a "contain" model by the stage aspect): when
- Проверено на живом HA: f1→196%, f2→140%, двор нетронут — после переключений zoomed in the content fills the width without black bars on the sides. Fully zoomed out the
значения восстанавливаются; сброс даёт 100% и полный план (874≤992 px по высоте). whole plan is visible (both width and height), the margins around it are filled with the card
background, not black.
- All coordinate math (markup click `_svgPoint`, icon drag, pan, pinch) was rewritten
in unified `view` coordinates — editing is correct at any zoom. A ResizeObserver on the stage
recomputes the `view` when dimensions change (sidebar, rotation). Pan/zoom are constrained to `fit`.
## v1.8.1 — 2026-07-04 (fit-to-viewport + per-space zoom persistence)
- Zooming out to the full view: the lower zoom limit = 100% coincides with the fully
visible plan. `.stage` is now constrained by the viewport height
(`width:min(100%, calc((100dvh 118px) × aspect))`, `max-width:100%`, centering),
so on reset/zoom-out the whole layout is visible both in width and height without clipping.
- The zoom level is remembered per space and locally per device
(`localStorage['houseplan_card_zoom_v1']`, `_zoomBySpace`): switching floors
restores each floor's zoom; on return the pan is centered and constrained.
- Verified on live HA: f1→196%, f2→140%, the yard untouched — after switching around the
values are restored; reset gives 100% and the full plan (874≤992 px in height).
## v1.8.0 — 2026-07-04 (зумирование плана) ## v1.8.0 — 2026-07-04 (plan zooming)
- Зум и панорама схемы: колёсико мыши (к курсору), щипок двумя пальцами (pinch) на тач-экране, - Zoom and pan of the layout: mouse wheel (towards the cursor), two-finger pinch on a touch screen,
перетаскивание фона одним пальцем при увеличении, кнопки −/сброс/+ в тулбаре, бейдж масштаба. one-finger background drag when zoomed in, /reset/+ buttons in the toolbar, a zoom badge.
- Реализовано через CSS-transform на `.zoomwrap` (translate+scale); координатная математика - Implemented via a CSS transform on `.zoomwrap` (translate+scale); the coordinate math
(клик разметки, drag иконок, тултипы) пересчитана с учётом зума/пана — редактирование (markup clicks, icon drag, tooltips) was recomputed with zoom/pan in mind — editing
работает и при увеличении. Пан ограничен, чтобы контент всегда покрывал сцену. Зум 100800%. works when zoomed in too. Pan is constrained so the content always covers the stage. Zoom 100800%.
- `.stage`: overflow hidden + touch-action none (свои жесты вместо браузерных). - `.stage`: overflow hidden + touch-action none (custom gestures instead of the browser's).
## v1.7.4 — 2026-07-04 (PDF по HTTP вместо WebSocket) ## v1.7.4 — 2026-07-04 (PDF over HTTP instead of WebSocket)
- КОРНЕВАЯ ПРИЧИНА невозможности загрузить PDF: файл слался как base64 одним WebSocket-сообщением, - THE ROOT CAUSE of being unable to upload a PDF: the file was sent as base64 in a single WebSocket
а у WS есть лимит размера — реальный мануал (>2–3 МБ) превышал его, соединение рвалось message, and WS has a message-size limit — a real manual (>23 MB) exceeded it, the connection dropped
(«Connection lost»), из-за чего и падала загрузка, и закрывался диалог (обрыв WS → переподключение ("Connection lost"), which both failed the upload and closed the dialog (WS drop → reconnect
ре-рендер). Подтверждено тестом: 1 МБ по WS ок, 3 МБ → Connection lost. re-render). Confirmed by test: 1 MB over WS is fine, 3 MB → Connection lost.
- Файлы-инструкции теперь грузятся через HTTP-эндпоинт `POST /api/houseplan/upload` (multipart, - Manual files are now uploaded through the HTTP endpoint `POST /api/houseplan/upload` (multipart,
HomeAssistantView, requires_auth, admin_only-проверка) — как медиа в самом HA. Проверено: HomeAssistantView, requires_auth, admin_only check) — like media in HA itself. Verified:
3 МБ (182мс) и 10 МБ (446мс) загружаются на ура. 3 MB (182 ms) and 10 MB (446 ms) upload without a hitch.
- Читаемые ошибки везде (`_errText`): больше никаких «[object Object]»; понятные тексты для - Readable errors everywhere (`_errText`): no more "[object Object]"; clear texts for
too_large / bad_ext / unauthorized. too_large / bad_ext / unauthorized.
- WS-команда houseplan/file/set оставлена для совместимости, но фронт использует HTTP. - The WS command houseplan/file/set is kept for compatibility, but the frontend uses HTTP.
## v1.7.3 — 2026-07-04 (фиксы диалога устройства) ## v1.7.3 — 2026-07-04 (device dialog fixes)
- PDF-инструкции не загружались: ручной base64 (`btoa(String.fromCharCode(...subarray))`) падал - PDF manuals failed to upload: the manual base64 (`btoa(String.fromCharCode(...subarray))`) crashed
на реальных файлах (RangeError при спреде больших массивов), а при закрытии диалога во время on real files (RangeError when spreading large arrays), and when the dialog was closed during an
await бросал null-доступ. Заменено на `FileReader.readAsDataURL` (надёжно для любого размера), await it threw a null access. Replaced with `FileReader.readAsDataURL` (reliable for any size);
результаты аккумулируются и добавляются, только если диалог ещё открыт (без исключений). the results are accumulated and added only if the dialog is still open (no exceptions).
- Диалог редактирования устройства/пространства/комнаты закрывался по клику мимо и «сам» - The device/space/room edit dialog closed on an outside click and "by itself"
(случайный клик по фон-оверлею, в т.ч. после закрытия системного выбора файла). Убрано (an accidental click on the background overlay, including after closing the system file picker). Removed
закрытие по клику на фон у диалогов-редакторов — только явные Отмена/Сохранить/Esc. background-click closing for editor dialogs — only explicit Cancel/Save/Esc.
Инфо-карточка (только чтение) по-прежнему закрывается кликом мимо. The info card (read-only) still closes on an outside click.
- Проверено на живом HA: PDF прикрепляется; клик по фону и обновление данных диалог не закрывают. - Verified on live HA: PDFs attach; a background click and dialog data refresh do not close the dialog.
## v1.7.2 — 2026-07-04 (фикс мобильного «ошибка конфигурации») ## v1.7.2 — 2026-07-04 (fix for the mobile "configuration error")
- ПРИЧИНА: карточка подключалась только через `add_extra_js_url` (extra_module_url), загрузку - THE CAUSE: the card was hooked up only via `add_extra_js_url` (extra_module_url), whose loading the
которого фронтенд НЕ дожидается — на холодном старте мобильного приложения дашборд рисовался frontend does NOT wait for — on a cold start of the mobile app the dashboard was drawn
раньше регистрации элемента → «ошибка конфигурации». (Все рабочие HACS-карточки — Lovelace-ресурсы.) before the element was registered → "configuration error". (All working HACS cards are Lovelace resources.)
- ФИКС 1: интеграция регистрирует карточку как **Lovelace-ресурс** (module) — их фронтенд ждёт - FIX 1: the integration registers the card as a **Lovelace resource** (module) — the frontend waits for
перед рендером. Идемпотентно (обновляет URL при смене версии, без дублей); фолбэк на those before rendering. Idempotent (updates the URL on a version change, no duplicates); a fallback to
extra_module_url для YAML-режима Lovelace. `_register_lovelace_resource` в __init__.py. extra_module_url for Lovelace YAML mode. `_register_lovelace_resource` in __init__.py.
- ФИКС 2: `_loadFromServer` ретраит (до 8 попыток по обновлениям hass) — если hass пришёл раньше - FIX 2: `_loadFromServer` retries (up to 8 attempts on hass updates) — if hass arrived before
готовности WS, карточка не застревает на онбординге, а дожидается конфига. WS readiness, the card does not get stuck on the onboarding but waits for the config.
- Проверено: ресурс houseplan-card.js зарегистрирован, extra_module_url пуст (единый чистый - Verified: the houseplan-card.js resource is registered, extra_module_url is empty (a single clean
модуль), карточка рендерит план на свежей вкладке без ошибки. module), the card renders the plan on a fresh tab without the error.
## v1.7.0 — 2026-07-04 (аудит + рефакторинг + тесты) ## v1.7.0 — 2026-07-04 (audit + refactoring + tests)
- Удалён вшитый в бандл дом-образец (дача, ~245 КБ base64 планов + ROOMS/FLOOR_*): бандл - Removed the sample house baked into the bundle (the dacha, ~245 KB of base64 plans + ROOMS/FLOOR_*): the bundle
293 КБ → 83 КБ. Свежая установка показывает онбординг «Добавить пространство», а не чужой дом. went 293 KB → 83 KB. A fresh install shows the "Add a space" onboarding, not someone else's house.
- Удалён код миграции legacy→сервер (дача уже на server config) и мёртвые пути - Removed the legacy→server migration code (the dacha is already on the server config) and the dead
device_overrides/virtual_devices (заменены markers[]). device_overrides/virtual_devices paths (replaced by markers[]).
- Чистая логика вынесена в src/logic.ts (lqiColor, snapToGrid, segKey, pointInPolygon, - Pure logic extracted into src/logic.ts (lqiColor, snapToGrid, segKey, pointInPolygon,
markerIdForBinding, averageLqi) — покрыта юнит-тестами (node:test, 9 тестов + iconFor). markerIdForBinding, averageLqi) — covered by unit tests (node:test, 9 tests + iconFor).
- Бэкенд: валидация/санитайзеры вынесены в validation.py (без HA-импортов) — pytest, 10 тестов. - Backend: validation/sanitizers extracted into validation.py (no HA imports) — pytest, 10 tests.
- БЕЗОПАСНОСТЬ: тест выявил, что санитайзер имён файлов/маркеров сохранял ведущие точки - SECURITY: a test revealed that the file/marker name sanitizer preserved leading dots
(риск обхода каталогов через «..»); добавлено срезание ведущих точек, пустое → misc/file. (a directory traversal risk via ".."); leading-dot stripping added, empty → misc/file.
- Исправлены реальные баги, ранее замаскированные обрезанным rules.ts: DOMAIN_PRIORITY был - Fixed real bugs previously masked by the truncated rules.ts: DOMAIN_PRIORITY was
усечён (ломало выбор more-info-сущности); _defaultPositions падал на полигон-комнатах cut short (breaking the more-info entity selection); _defaultPositions crashed on polygon rooms
(теперь bbox). tsc --noEmit добавлен в сборку и CI — строгая типизация проходит. (now bbox). tsc --noEmit added to the build and CI — strict typing passes.
- Убраны дубли-мусор src/data/{editor,rules}.ts; версии синхронизированы (manifest застрял на 1.4.0). - Removed the junk duplicates src/data/{editor,rules}.ts; versions synchronized (the manifest was stuck at 1.4.0).
- CI: добавлены typecheck, юнит-тесты фронта и бэка. - CI: added typecheck, frontend and backend unit tests.
## v1.6.2 — 2026-07-04 ## v1.6.2 — 2026-07-04
- Поле «Комната» в диалоге устройства теперь для ВСЕХ значков (не только виртуальных): - The "Room" field in the device dialog is now for ALL icons (not only virtual ones):
для привязанных — «переопределить размещение» (по умолчанию по зоне устройства); смена for bound ones it "overrides the placement" (by default, the device's area); changing the
комнаты переставляет значок в её центр. Сохраняется в marker.space/area и выигрывает у зоны HA. room moves the icon to its center. Stored in marker.space/area and wins over the HA area.
- В инфо-карточке добавлена кнопка «Редактировать» — открывает диалог редактирования устройства. - An "Edit" button was added to the info card — it opens the device edit dialog.
## v1.6.0 — 2026-07-04 (Фаза 3: редактор устройств) ## v1.6.0 — 2026-07-04 (Phase 3: device editor)
- Модель «маркеров» (config.markers[]): гибрид — авто-устройства HA появляются сами, маркеры - The "markers" model (config.markers[]): a hybrid — auto-discovered HA devices appear by themselves, markers
правят/перепривязывают/дополняют их и описывают ручные/виртуальные значки. id маркера edit/rebind/augment them and describe manual/virtual icons. The marker id
сохраняет позицию (device→device_id, entity→lg_<eid>, virtual→v_<rand>). preserves the position (device→device_id, entity→lg_<eid>, virtual→v_<rand>).
- Клик по значку: обычный режим → инфо-карточка (имя, модель, состояние, ссылка, описание, - Clicking an icon: normal mode → the info card (name, model, state, link, description,
PDF-инструкции, кнопка «Открыть в HA»); режим правки → диалог редактирования. PDF manuals, an "Open in HA" button); edit mode → the edit dialog.
- Диалог устройства: имя, привязка (пикер всех устройств/групп HA+Z2M+хелперов с текстовым - The device dialog: name, binding (a picker of all HA+Z2M devices/groups+helpers with a text
фильтром, минус уже размещённые и дубли по имя|area, + «Виртуальное устройство»), MDI-иконка filter, minus already-placed ones and duplicates by name|area, plus "Virtual device"), MDI icon
(ha-icon-picker), модель, ссылка, описание, прикрепление файлов-инструкций (PDF на сервер), (ha-icon-picker), model, link, description, manual file attachment (PDF to the server),
комната (для виртуальных), Убрать/Отмена/Сохранить. room (for virtual ones), Remove/Cancel/Save.
- Тулбар: кнопка «+» (тултип «Добавить устройство») — пустой диалог. - Toolbar: a "+" button (tooltip "Add device") — an empty dialog.
- Бэкенд: MARKER_SCHEMA в config; WS houseplan/file/set (файлы в /config/houseplan/files/, - Backend: MARKER_SCHEMA in the config; WS houseplan/file/set (files in /config/houseplan/files/,
≤25 МБ, отдача /houseplan_files/files/); статика files зарегистрирована. ≤25 MB, served from /houseplan_files/files/); the files static path registered.
- Виртуальные значки теперь перетаскиваются и позиционируются как обычные (layout по id). - Virtual icons are now draggable and positioned like normal ones (layout by id).
## v1.6.1 — 2026-07-04 ## v1.6.1 — 2026-07-04
- Пикер привязки прячет дубли устройств по «имя|area» (Tuya/LocalTuya), как дедуп на плане. - The binding picker hides device duplicates by "name|area" (Tuya/LocalTuya), same as the dedup on the plan.
## v1.5.1 — 2026-07-04 (управление пространствами) ## v1.5.1 — 2026-07-04 (space management)
- Иконка-карандаш справа от названия каждого пространства в верхнем тулбаре + кнопка «+» - A pencil icon to the right of each space name in the top toolbar + a "+" button
(только при server config). (only with a server config).
- Диалог пространства: поле «Название», превью текущей подложки + «Загрузить/Заменить…» - The space dialog: a "Name" field, a preview of the current background + "Upload/Replace…"
(SVG/PNG/JPG/WebP, base64 → houseplan/plan/set, пропорции определяются из файла), (SVG/PNG/JPG/WebP, base64 → houseplan/plan/set, the aspect ratio is detected from the file),
кнопки Удалить / Отмена / Сохранить. Создание пустого пространства и удаление (со всеми Delete / Cancel / Save buttons. Creating an empty space and deletion (together with all its
комнатами и разметкой, с подтверждением). Сохранение через config/set с ревизией. rooms and markup, with a confirmation). Saved via config/set with a revision.
## v1.5.0 — 2026-07-04 (группы света = сущности) ## v1.5.0 — 2026-07-04 (light groups = entities)
- Лампы заменены сущностями групп: HA light-groups (platform=group, area из реестра сущности) - Lamps replaced by group entities: HA light groups (platform=group, area from the entity registry)
и Z2M-группы (device model=Group) рендерятся иконкой mdi:lightbulb-group; клик → more-info and Z2M groups (device model=Group) are rendered with the mdi:lightbulb-group icon; a click → the group's
группы (управление всей группой и участниками штатно в HA). more-info (controlling the whole group and its members natively in HA).
- Одиночные лампы скрываются в комнатах, где есть группа света (settings.group_lights=false - Individual lamps are hidden in rooms that have a light group (settings.group_lights=false
возвращает поштучный режим). brings back the per-lamp mode).
- Выпилена старая визуальная группировка и кастомное меню участников (упрощение кода). - The old visual grouping and the custom member menu were removed (code simplification).
- Позиции старых групп (grp_<area>) перенесены на новые id (lg_<entity_id>) в раскладке дачи. - The positions of the old groups (grp_<area>) were migrated to the new ids (lg_<entity_id>) in the dacha layout.
## v1.4.4 — 2026-07-04 (КРИТИЧЕСКИЙ фикс: гонка конфигураций) ## v1.4.4 — 2026-07-04 (CRITICAL fix: configuration race)
- ИНЦИДЕНТ: config сохранялся целиком по принципу last-writer-wins — открытый клиент со - INCIDENT: the config was saved wholesale on a last-writer-wins basis — an open client with a
старой копией затирал чужие правки (потеряна первая итерация пользовательской разметки). stale copy wiped other clients' edits (the first iteration of the user's markup was lost).
- Фикс: оптимистичная блокировка — конфиг несёт `rev`; `config/set` принимает `expected_rev` - Fix: optimistic locking — the config carries a `rev`; `config/set` accepts `expected_rev`
и возвращает ошибку `conflict` при несовпадении; клиент перечитывает конфиг. and returns a `conflict` error on mismatch; the client re-reads the config.
- Live-синхронизация: `config/set` рассылает событие `houseplan_config_updated`; все открытые - Live synchronization: `config/set` broadcasts a `houseplan_config_updated` event; all open
карточки подписаны и перечитывают конфиг автоматически (окна больше не расходятся). cards are subscribed and re-read the config automatically (windows no longer diverge).
- Позиции при drag сохраняются точечно через `layout/update` (dirty-set), а не полным - Positions during drag are saved pointwise via `layout/update` (dirty set), not a full
`layout/set`раскладка тоже больше не затирается между окнами. `layout/set`the layout is also no longer wiped between windows.
- Бэкап конфига перед деплоем: .storage/houseplan.config.bak-*. - A config backup before deployment: .storage/houseplan.config.bak-*.
## v1.4.3 — 2026-07-04 ## v1.4.3 — 2026-07-04
- Иконки устройств привязываются к той же сетке, что и разметка (центр иконки = узел, снап - Device icons snap to the same grid as the markup (the icon center = a node, snapping
при перетаскивании и вводе X/Y). Существующие 57 позиций в БД дачи снапнуты к узлам. on drag and on X/Y input). The existing 57 positions in the dacha DB were snapped to the nodes.
## v1.4.2 — 2026-07-04 (сетка разметки) ## v1.4.2 — 2026-07-04 (markup grid)
- Шаг сетки уменьшен в 2 раза: GRID_N 60 → 120 (шаг 8.33 рендер-ед. ≈ 0.83% ширины плана). - The grid step was halved: GRID_N 60 → 120 (step 8.33 render units ≈ 0.83% of the plan width).
- Точки сетки рендерятся ПОВЕРХ плана и комнат (grid-rect перенесён из под-плана в верхний - Grid points are rendered ON TOP of the plan and rooms (the grid-rect was moved from below the plan
markup-слой); точки контрастнее (r=0.14g, обводка). into the top markup layer); the points are higher-contrast (r=0.14g, outline).
- Данные дачи: границы всех 13 комнат привязаны к узлам сетки (снап рёбер, сдвиги ≤ полшага; - Dacha data: the boundaries of all 13 rooms were snapped to grid nodes (edge snapping, shifts ≤ half a step;
правка только конфига инстанса через houseplan/config/set, код не менялся). only the instance config was edited via houseplan/config/set, the code was untouched).
- Кэш карточки: URL модуля ?v= берётся из const.VERSION — при обновлении фронтенда бампать - Card cache: the module URL ?v= is taken from const.VERSION — when updating the frontend, bump
const.py и рестартовать HA, иначе браузеры держат старый модуль в memory-cache. const.py and restart HA, otherwise browsers keep the old module in memory cache.
## v1.4.1 — 2026-07-04 (UX разметки) ## v1.4.1 — 2026-07-04 (markup UX)
- Esc / Ctrl+Z при рисовании убирают последнюю точку (и её линию, если она была добавлена - Esc / Ctrl+Z while drawing remove the last point (and its line, if it was added
этим шагом; переиспользованные чужие стены не трогаются). by that step; reused walls belonging to others are untouched).
- Панель редактирования (разметка и правка раскладки) перенесена наверх под шапку карточки - The editing panel (markup and layout editing) was moved to the top under the card header
и закреплена вместе с ней (общий sticky-контейнер .hdr). and pinned together with it (a shared sticky container .hdr).
- Замыкание контура автоматически открывает модальный диалог «Новая комната»: отображаемое - Closing the outline automatically opens the "New room" modal dialog: the display
имя, выпадающий список ТОЛЬКО свободных зон HA (не назначенных ни одной комнате конфига), name, a dropdown of ONLY the free HA areas (not assigned to any room of the config),
Сохранить/Отмена. Отмена (или Esc) снимает замыкающую точку — контур можно продолжить. Save/Cancel. Cancel (or Esc) removes the closing point — the outline can be continued.
Выбор зоны при пустом имени подставляет название зоны. Choosing an area with an empty name fills in the area's name.
## v1.4.0 — 2026-07-04 (Фаза 2: редактор разметки комнат) ## v1.4.0 — 2026-07-04 (Phase 2: room markup editor)
- Режим «Разметка» в карточке (кнопка mdi:vector-square-edit, только при server config): - The "Markup" mode in the card (the mdi:vector-square-edit button, only with a server config):
сетка точек (60 узлов по ширине), линии парами кликов со снапом к узлам, полилиния-превью. a grid of points (60 nodes across the width), lines drawn by pairs of clicks with node snapping, a polyline preview.
- Замкнутый контур (клик по первой точке) активирует «Сохранить»: выбор зоны HA из - A closed outline (a click on the first point) activates "Save": choosing an HA area from a
выпадающего списка + название; комната сохраняется полигоном (poly, нормированные вершины). dropdown + a name; the room is saved as a polygon (poly, normalized vertices).
- Инструменты: Добавить (рисование), Стереть линию (клик по линии), Удалить комнату - Tools: Add (drawing), Erase line (a click on a line), Delete room
(клик внутри + confirm). Линии-«стены» хранятся в space.segments и переиспользуются (a click inside + confirm). The "wall" lines are stored in space.segments and reused by
соседними комнатами (общие стены — кликами по существующим узлам). neighboring rooms (shared walls — via clicks on existing nodes).
- Рендер комнат: полигоны наравне с прямоугольниками (hover, клик в зону, LQI-тултип, - Room rendering: polygons on par with rectangles (hover, click into the area, LQI tooltip,
подпись в центроиде); в режиме разметки контуры и названия всех комнат видимы. the label at the centroid); in markup mode the outlines and names of all rooms are visible.
- Бэкенд: ROOM_SCHEMA принимает poly (≥3 вершин) или x/y/w/h; SPACE_SCHEMA — segments. - Backend: ROOM_SCHEMA accepts poly (≥3 vertices) or x/y/w/h; SPACE_SCHEMA — segments.
## v1.3.0 — 2026-07-04 (Фаза 1: серверная конфигурация) ## v1.3.0 — 2026-07-04 (Phase 1: server-side configuration)
- Store `houseplan.config`: spaces (план, aspect, view_box, комнаты), device_overrides - The `houseplan.config` Store: spaces (plan, aspect, view_box, rooms), device_overrides
(hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights). (hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights).
- WS: `houseplan/config/get|set`, `houseplan/plan/set` (загрузка файла плана base64 → - WS: `houseplan/config/get|set`, `houseplan/plan/set` (plan file upload as base64 →
`<config>/houseplan/plans/`, отдача через /houseplan_files/plans/). `<config>/houseplan/plans/`, served through /houseplan_files/plans/).
- Карточка: резолв-модель — server config (координаты НОРМИРОВАННЫЕ 0..1, рендер 1000×1000/aspect) - The card: a resolve model — the server config (coordinates NORMALIZED 0..1, render 1000×1000/aspect)
или legacy-бандл (холст 1489×1053). Раскладка v2: {device_id: {s, x, y}} нормированная. or the legacy bundle (canvas 1489×1053). Layout v2: {device_id: {s, x, y}} normalized.
- Кнопка «На сервер» в режиме правки — миграция legacy-конфига в один клик (планы, комнаты, - The "To server" button in edit mode — a one-click migration of the legacy config (plans, rooms,
view_box, раскладка). Выполнена на даче: .storage/houseplan.config + plans/f1.svg,f2.svg. view_box, layout). Performed at the dacha: .storage/houseplan.config + plans/f1.svg,f2.svg.
- Read-side поддержка оверрайдов устройств и виртуальных устройств (UI управления — фазы 34). - Read-side support for device overrides and virtual devices (the management UI — phases 34).
## v1.2.2 — 2026-07-04 ## v1.2.2 — 2026-07-04
- Тулбар карточки (вкладки этажей) закрепляется при скролле под шапкой HA - The card toolbar (floor tabs) pins under the HA header while scrolling
(`position: sticky; top: var(--header-height)`; ha-card overflow: visible). (`position: sticky; top: var(--header-height)`; ha-card overflow: visible).
- Инцидент: промежуточная сборка на нестабильном mount дала битый бандл, роняющий рендер - Incident: an intermediate build on the unstable mount produced a broken bundle that crashed the
дашбордов; правило «собирать только в /tmp + md5-контроль» закреплено в DEVELOPMENT.md. dashboard rendering; the rule "build only in /tmp + md5 control" was locked into DEVELOPMENT.md.
## v1.2.1 — 2026-07-04 ## v1.2.1 — 2026-07-04
- Иконки не увеличиваются при наведении/перетаскивании (убран transform: scale). - Icons no longer enlarge on hover/drag (transform: scale removed).
## v1.2.0 — 2026-07-03 ## v1.2.0 — 2026-07-03
- Границы комнат сняппены к стенам векторного плана. - Room boundaries snapped to the walls of the vector plan.
- Zigbee LQI: значение под иконкой, среднее по комнате в тултипе, градиент красный→зелёный, - Zigbee LQI: the value under the icon, the room average in the tooltip, a red→green gradient,
опция show_signal. the show_signal option.
- `mdi:lock` для любых устройств с сущностью lock.* (TTLock). - `mdi:lock` for any devices with a lock.* entity (TTLock).
- Выключатель прихожей: исправлена зона устройства в реестре HA (был в detskaia_elina). - The hallway switch: the device's area in the HA registry was fixed (it was in detskaia_elina).
## v1.1.0 — 2026-07-03 ## v1.1.0 — 2026-07-03
- Векторные подложки (SVG РЕМПЛАННЕР), автосовмещение масштаба/сдвига растровой корреляцией. - Vector backgrounds (SVG from REMPLANNER), automatic scale/offset alignment via raster correlation.
- Размер иконок в % ширины плана (container queries, дефолт 2.5%). - Icon size as a % of the plan width (container queries, default 2.5%).
## v1.0.1 — 2026-07-03 ## v1.0.1 — 2026-07-03
- fix: вложенные SVG-фрагменты через lit svg`` (подложка/комнаты не рендерились). - fix: nested SVG fragments via lit svg`` (the background/rooms were not rendered).
- fix: версия из const вместо блокирующего чтения manifest.json в event loop. - fix: the version taken from const instead of a blocking manifest.json read in the event loop.
## v1.0.0 — 2026-07-03 ## v1.0.0 — 2026-07-03
- Первый релиз: Lovelace-карточка (TS+Lit, без токена, от hass) + интеграция houseplan - First release: the Lovelace card (TS+Lit, no token, driven by hass) + the houseplan integration
(WS-хранилище раскладки, раздача JS). Перенос модели прототипа: курирование, группы ламп, (WS layout storage, JS serving). The prototype's model carried over: curation, lamp groups,
iconFor, температура, more-info, навигация в зоны, drag-раскладка. iconFor, temperature, more-info, area navigation, drag layout.
+53 -53
View File
@@ -1,77 +1,77 @@
# Разработка и деплой # Development and deployment
## Окружение (cowork-сессии) ## Environment (cowork sessions)
- Эталон кода — **git-репозиторий** (в сессии живёт в `/tmp/hpc`, восстанавливается из - The source of truth for the code is the **git repository** (lives in `/tmp/hpc` during a session,
`houseplan-card.git.bundle`: `git clone houseplan-card.git.bundle hpc`). restored from `houseplan-card.git.bundle`: `git clone houseplan-card.git.bundle hpc`).
- Папка пользователя `houseplan/houseplan-card/` — зеркало репо (rsync после каждого коммита) - The user's folder `houseplan/houseplan-card/` is a mirror of the repo (rsync after every commit)
+ актуальный `houseplan-card.git.bundle`. + an up-to-date `houseplan-card.git.bundle`.
### ⚠️ Грабли файловой синхронизации (критично) ### ⚠️ File-sync pitfalls (critical)
1. Сетевой mount иногда отдаёт файлы **обрезанными/перемешанными** — правки через Edit-tool 1. The network mount sometimes serves files **truncated/scrambled** — edits via the Edit tool
с Windows-стороны ненадёжны. Правило: **править python-патчами по чистой копии в /tmp, from the Windows side are unreliable. Rule: **apply python patches against a clean copy in /tmp,
писать через bash**, assert на count(old)==1. write via bash**, with an assert that count(old)==1.
2. **Сборку rollup вести ТОЛЬКО в /tmp/hpc** (`npm ci` уже сделан). Сборка на mount однажды 2. **Run the rollup build ONLY in /tmp/hpc** (`npm ci` is already done). A build on the mount once
дала синтаксически валидный, но битый бандл («wi is not defined»), который валил рендер produced a syntactically valid but broken bundle ("wi is not defined") that crashed the rendering
ВСЕХ дашбордов HA (карточка грузится как extra_module на каждой странице!). of ALL HA dashboards (the card is loaded as an extra_module on every page!).
3. `.git` на mount не создаётся («Operation not permitted» на dot-каталогах) — поэтому bundle. 3. `.git` cannot be created on the mount ("Operation not permitted" on dot-directories) — hence the bundle.
## Тесты ## Tests
- Фронт: `npm test`компилит src/logic.ts+rules.ts (tsconfig.test.json) и гоняет node:test - Frontend: `npm test`compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
(test/*.test.mjs). Строгая типизация: `npm run typecheck` (tsc --noEmit, входит в `npm run build`). (test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
- Бэк: `python -m pytest tests_backend/`чистая валидация custom_components/houseplan/validation.py - Backend: `python -m pytest tests_backend/`pure validation of custom_components/houseplan/validation.py
(грузится по пути, без импорта HA-пакета). (loaded by path, without importing the HA package).
- ВАЖНО (аудит-урок): rollup-плагин typescript выдаёт синтаксическую ошибку как WARNING и всё равно - IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
собирает бандл — обрезанный файл может «пройти». Поэтому в сборке первым идёт `tsc --noEmit`, builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`,
который падает на таких ошибках. Всегда собирать `npm run build`, не голый `rollup -c`. which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
## Сборка ## Build
```bash ```bash
cd /tmp/hpc && npm ci # один раз cd /tmp/hpc && npm ci # once
npx rollup -c # → dist/houseplan-card.js npx rollup -c # → dist/houseplan-card.js
node --check dist/houseplan-card.js node --check dist/houseplan-card.js
cp dist/houseplan-card.js custom_components/houseplan/frontend/ cp dist/houseplan-card.js custom_components/houseplan/frontend/
``` ```
## Деплой на дачу (ha.jbstudio.pro) ## Deployment to the dacha (ha.jbstudio.pro)
- SSH: порт **323**, root, ключ `ha_jb` (пользователь загружает в чат; в песочнице /tmp/ha_jb, chmod 600). - SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/` - JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
- Интеграция целиком: tar c custom_components/houseplan (--exclude __pycache__) → tar x на сервере. - The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
- **Проверка обязательна**: `md5sum` локально == на сервере == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum` - **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
(внутри SSH-аддона `localhost` — НЕ HA, использовать хост `homeassistant`). (inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
- Изменения Python требуют рестарта HA (`ha core restart`, держит соединение до конца, HTTP - Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
поднимается через 1–3 мин). Изменения JS — только обновление страницы (static path отдаётся comes back up in 13 min). JS changes — just a page refresh (the static path is served
с no-cache). with no-cache).
- После деплоя JS — проверить в браузере (Ctrl+F5) и console (не должно быть ошибок из - After deploying JS — check in the browser (Ctrl+F5) and the console (there must be no errors from
houseplan-card.js; битый бандл роняет все дашборды). houseplan-card.js; a broken bundle takes down all dashboards).
## Кэш фронтенда и «пустой вид» ## Frontend cache and the "empty view"
- URL модуля карточки содержит `?v=<VERSION из const.py>`. Браузеры держат ES-модуль в - The card module URL contains `?v=<VERSION from const.py>`. Browsers keep the ES module in
memory-cache: после деплоя нового JS **бампните VERSION в const.py и рестартуйте HA**, memory cache: after deploying new JS **bump VERSION in const.py and restart HA**,
иначе обычный F5 оставит старую версию. otherwise a plain F5 will keep the old version.
- После reload страницы HA-фронтенд (с kiosk-mode) иногда оставляет вид пустым - After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty
(«InvalidStateError: Transition was aborted», hui-view не создаётся 12 мин). ("InvalidStateError: Transition was aborted", hui-view is not created for 12 min).
Лечится повторной SPA-навигацией: pushState + событие location-changed, или подождать. Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
## Релиз ## Release
Тег `vX.Y.Z` + GitHub Release → workflow `.github/workflows/release.yml` собирает и прикладывает Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
`houseplan-card.js`. Версию бампать синхронно: `src/houseplan-card.ts` (CARD_VERSION), `houseplan-card.js`. Bump the version everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION),
`package.json`, `custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`. `package.json`, `custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
## Воспроизводимые скрипты (данные) ## Reproducible scripts (data)
- Извлечение геометрии/подложек из прототипа и генерация `src/data/*` — см. историю коммитов - Extracting the geometry/backgrounds from the prototype and generating `src/data/*` — see the commit
и docs/ARCHITECTURE.md (трансформации SVG→базовое пространство: f1 0.647/(490,27), f2 0.896/(351,21)). history and docs/ARCHITECTURE.md (SVG→base-space transforms: f1 0.647/(490,27), f2 0.896/(351,21)).
- Подгонка комнат: рендер плана с прямоугольниками поверх (cv2) → снап к стенам → ручная доводка. - Room fitting: render the plan with rectangles overlaid (cv2) → snap to walls → manual fine-tuning.
## Прод-объекты в HA (дача) ## Production objects in HA (the dacha)
- Дашборд `plan-doma`, панельный вид, карточка `custom:houseplan-card` (icon_size 2.5). - Dashboard `plan-doma`, panel view, card `custom:houseplan-card` (icon_size 2.5).
- Интеграция houseplan: entry загружен, `.storage/houseplan.layout`раскладка (сервер). - The houseplan integration: entry loaded, `.storage/houseplan.layout`the layout (server-side).
- Старый прототип `/config/www/houseplan/` (iframe) сохранён как запасной, не трогать. - The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
- Бэкапы configuration.yaml: `.bak-avgtemp` (до правки среднего датчика). - configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
+56 -56
View File
@@ -1,73 +1,73 @@
# Роадмап: от «карты дачи» к публикуемой универсальной интеграции # Roadmap: from a "dacha map" to a publishable universal integration
Цель: опубликовать в HACS (сначала custom repository, затем PR в default) универсальную Goal: publish to HACS (first as a custom repository, then a PR into default) a universal
интеграцию «интерактивный план дома»: свои планы на пространство, ручная разметка комнат, "interactive house plan" integration: custom plans per space, manual room markup,
полуавтоматическое размещение устройств, скрытие/переименование/смена иконок, виртуальные semi-automatic device placement, hiding/renaming/changing icons, virtual
устройства. Никакого хардкода конкретного дома в коде. devices. No hardcoding of a specific house in the code.
## Принципы (зафиксировано) ## Principles (locked in)
- Пишем полноценный компонент по паттернам HA dev docs, не захардкоженную фичу. - We write a proper full component following HA dev docs patterns, not a hardcoded feature.
- Все данные конкретного дома — это **конфигурация инстанса** (server-side Store), - All data of a specific house is **instance configuration** (server-side Store),
а не код/бандл. Текущие данные дачи станут первым мигрированным инстансом. not code/bundle. The current dacha data will become the first migrated instance.
- Документировать всё сразу в docs/ (контекст сессий теряется). - Document everything immediately in docs/ (session context gets lost).
- Версионирование хранилищ (Store minor_version + async_migrate) с первого дня. - Storage versioning (Store minor_version + async_migrate) from day one.
## Фаза 0 — Гигиена публикации (быстро, без новых фич) ## Phase 0 — Publication hygiene (quick, no new features)
- [x] hacs.json, manifest с обязательными ключами, структура custom_components/* - [x] hacs.json, manifest with the required keys, custom_components/* structure
- [x] CI: hacs/action + hassfest (workflow validate.yml) — добавлено, проверить на GitHub - [x] CI: hacs/action + hassfest (workflow validate.yml) — added, verify on GitHub
- [x] brand/icon.png - [x] brand/icon.png
- [ ] Публичный GitHub-репозиторий: description, topics, issues on; первый Release v1.2.x - [ ] Public GitHub repository: description, topics, issues on; first Release v1.2.x
- [ ] README EN (основной) + README.ru.md; скриншоты/GIF (обязательны для витрины HACS) - [ ] README EN (primary) + README.ru.md; screenshots/GIF (mandatory for the HACS storefront)
- [ ] Заменить codeowners/documentation/issue_tracker на реальные URL после создания репо - [ ] Replace codeowners/documentation/issue_tracker with real URLs after the repo is created
## Фаза 1 — Конфиг на сервере (декаплинг от дачи) ← ✅ СДЕЛАНО в v1.3.0 (кроме выпиливания бандл-данных — оставлены как fallback до фазы 2) ## Phase 1 — Server-side config (decoupling from the dacha) ← ✅ DONE in v1.3.0 (except removing the bundle data — kept as a fallback until phase 2)
Новое хранилище `houseplan.config` (Store v1): New store `houseplan.config` (Store v1):
```json ```json
{ "spaces": [ { "id": "f1", "title": "1 этаж", "plan": {"media_id": "...", "type": "svg"}, { "spaces": [ { "id": "f1", "title": "1st floor", "plan": {"media_id": "...", "type": "svg"},
"view_box": [x,y,w,h], "rooms": [{"id","name","area_id","x","y","w","h"}] } ], "view_box": [x,y,w,h], "rooms": [{"id","name","area_id","x","y","w","h"}] } ],
"device_overrides": { "<device_id>": {"hidden":bool,"icon":str,"name":str} }, "device_overrides": { "<device_id>": {"hidden":bool,"icon":str,"name":str} },
"virtual_devices": [ {"id","space","name","icon","x","y","note"?, "entity_id"?} ], "virtual_devices": [ {"id","space","name","icon","x","y","note"?, "entity_id"?} ],
"settings": {"exclude_integrations": [...], "group_lights": bool, ...} } "settings": {"exclude_integrations": [...], "group_lights": bool, ...} }
``` ```
- WS API v2: `houseplan/config/get|set`, `houseplan/plan/upload` (файл плана - WS API v2: `houseplan/config/get|set`, `houseplan/plan/upload` (plan file
`<config>/houseplan/` через process-executor, отдача через static path), layout как сейчас. `<config>/houseplan/` via the process executor, served through a static path), layout as today.
- Карточка: при наличии server-config использует его; бандл-данные дачи становятся - The card: when a server config exists it uses it; the dacha bundle data becomes a
**fallback-примером** и затем выпиливаются (миграционный скрипт зальёт их в Store). **fallback example** and is then removed (a migration script will push it into the Store).
- Единицы координат: нормированные (0..1 от плана) для новых конфигов — независимость от - Coordinate units: normalized (0..1 of the plan) for new configs — independence from the
разрешения исходника; миграция дачи пересчитает 1489×1053 → нормированные. source resolution; the dacha migration will convert 1489×1053 → normalized.
## Фаза 2 — Редактор разметки в карточке ← ✅ ЯДРО СДЕЛАНО в v1.4.0 (осталось: загрузка плана из UI, правка view_box, редактирование существующих комнат) ## Phase 2 — Markup editor in the card ← ✅ CORE DONE in v1.4.0 (remaining: plan upload from the UI, view_box editing, editing existing rooms)
- Режим «Настройка» (отдельно от drag-раскладки): рисование/ресайз прямоугольников комнат - A "Setup" mode (separate from drag layout): drawing/resizing room rectangles
поверх плана, привязка к area (селектор ha-area-picker), редактирование viewBox (кадр). on top of the plan, binding to an area (ha-area-picker selector), viewBox (frame) editing.
- Позже: полигональные комнаты (SVG path), повороты планов. - Later: polygonal rooms (SVG path), plan rotations.
- Загрузка плана из UI (file upload → WS) + выбор существующего media. - Plan upload from the UI (file upload → WS) + picking existing media.
## Фаза 3 — Управление устройствами ← ✅ СДЕЛАНО в v1.6.x (скрытие, иконка, имя, модель, ссылка, описание, PDF, перепривязка, «+», виртуальные) ## Phase 3 — Device management ← ✅ DONE in v1.6.x (hiding, icon, name, model, link, description, PDF, rebinding, "+", virtual)
- Панель устройств в режиме настройки: список неразмещённых (с фильтрами), drag из панели - A device panel in setup mode: a list of unplaced devices (with filters), drag from the panel
на план; авто-раскладка «сеткой по комнате» кнопкой. onto the plan; auto-layout "grid over the room" by button.
- Оверрайды per-device: скрыть, своя иконка (ha-icon-picker), своё имя. Хранение в config. - Per-device overrides: hide, custom icon (ha-icon-picker), custom name. Stored in config.
- Настраиваемое курирование: исключения интеграций/доменов в options flow вместо хардкода. - Configurable curation: integration/domain exclusions in the options flow instead of hardcode.
## Фаза 4 — Виртуальные устройства ← ✅ базово в v1.6.x (CRUD виртуальных маркеров в общем диалоге; отдельная доработка заметок/иконок по мере надобности) ## Phase 4 — Virtual devices ← ✅ basics in v1.6.x (CRUD of virtual markers in the shared dialog; further note/icon polish as needed)
- CRUD виртуальных маркеров (имя, иконка, координаты, заметка; опционально ссылка на - CRUD of virtual markers (name, icon, coordinates, note; optionally a link to an
entity/URL): септик, кран, счётчик без датчика и т.п. Рендер как обычные иконки, entity/URL): septic tank, valve, a meter without a sensor, etc. Rendered like normal icons,
клик → карточка с заметкой или more-info привязанной сущности. click → a card with the note or more-info of the bound entity.
## Фаза 5 — Полировка UX/фич ## Phase 5 — UX/feature polish
- Клик-действия по настройке: toggle для света/розеток, long-press → more-info. - Configurable click actions: toggle for lights/outlets, long-press → more-info.
- Тюнинг live-индикации (цвета по теме, badge), light-тема. - Live-indication tuning (theme-aware colors, badges), light theme.
- Тултипы на тач-устройствах (long-press), доступность (клавиатура, aria). - Tooltips on touch devices (long-press), accessibility (keyboard, aria).
- Опция LQI: порог «плохого» сигнала, скрытие меток на не-zigbee инстансах. - LQI option: "bad signal" threshold, hiding the labels on non-zigbee instances.
## Фаза 6 — Качество и публикация ## Phase 6 — Quality and publication
- Тесты: pytest (config_flow, websocket_api, миграции Store) + hassfest/hacs action в CI; - Tests: pytest (config_flow, websocket_api, Store migrations) + hassfest/hacs action in CI;
фронт: vitest на rules/geometry-утилиты. frontend: vitest for rules/geometry utilities.
- Типизация: strict TS-интерфейсы hass (custom-card-helpers или свои), mypy для python. - Typing: strict TS interfaces for hass (custom-card-helpers or our own), mypy for python.
- Переводы integration+card: en + ru (translations/, локализация строк карточки). - Translations for integration+card: en + ru (translations/, card string localization).
- Quality scale bronze → silver чек-лист; PR в hacs/default; опционально PR в - Quality scale bronze → silver checklist; PR into hacs/default; optionally a PR into
home-assistant/brands (пока хватает brand/ в репо). home-assistant/brands (the brand/ in the repo suffices for now).
## Открытые вопросы ## Open questions
- Имя для публикации: «House Plan Card»? домен `houseplan` не занят в HACS default — проверить. - Publication name: "House Plan Card"? the `houseplan` domain is not taken in HACS default — verify.
- Лицензия MIT (в package.json уже MIT) — добавить LICENSE файл. - MIT license (package.json is already MIT) — add a LICENSE file.
- Формат планов: SVG предпочтителен (вектор, вес), PNG поддержать обязательно. - Plan formats: SVG preferred (vector, size), PNG support is mandatory.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.10.0", "version": "1.11.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Публикация House Plan на GitHub + релиз. Запускать на своей машине, где ты залогинен в GitHub.
# Требуется установленный git и gh (GitHub CLI: https://cli.github.com), выполнить `gh auth login` один раз.
set -e
OWNER="Matysh" # владелец репозитория (github.com/Matysh)
REPO="houseplan-card"
# 1) распаковать репозиторий из бандла (если ещё нет рабочей копии)
# git clone houseplan-card.git.bundle "$REPO" && cd "$REPO"
# 2) создать ПУБЛИЧНЫЙ репозиторий и запушить всё + теги
gh repo create "$OWNER/$REPO" --public \
--description "Интерактивный план дома для Home Assistant: этажи, комнаты и устройства на настоящем плане. Всё через UI, без YAML." \
--source . --remote origin --push
# 3) добавить topics (для поиска в HACS)
gh repo edit "$OWNER/$REPO" --add-topic home-assistant --add-topic hacs \
--add-topic lovelace --add-topic floorplan --add-topic custom-integration --add-topic zigbee
# 4) опубликовать РЕЛИЗ (не просто тег) с заметками
git push origin v1.9.3
gh release create v1.9.3 --title "House Plan v1.9.3" --notes-file RELEASE_NOTES_v1.9.3.md
echo "Готово. Теперь репозиторий устанавливается как HACS Custom repository (категория Integration)."
+24 -22
View File
@@ -1,21 +1,23 @@
/** /**
* Построение списка устройств из реестров HA: курирование, группы света, * Building the device list from HA registries: curation, light groups,
* маркеры (оверрайды/виртуальные). Без Lit/DOM только hass-объект. * markers (overrides/virtual). No Lit/DOM only the hass object.
*/ */
import { iconFor, DOMAIN_PRIORITY } from './rules'; import { iconFor, DOMAIN_PRIORITY } from './rules';
import { averageLqi } from './logic'; import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types'; import type { DevItem, Marker, ServerConfig } from './types';
/** Контекст построения: срез hass + резолв конфига. */ /** Build context: a slice of hass + config resolution. */
export interface BuildCtx { export interface BuildCtx {
hass: any; hass: any;
/** area_id → space_id (только зоны, привязанные к комнатам). */ /** area_id → space_id (only zones bound to rooms). */
areaToSpace: Record<string, string>; areaToSpace: Record<string, string>;
markers: Marker[]; markers: Marker[];
settings: ServerConfig['settings']; settings: ServerConfig['settings'];
excluded: Set<string>; excluded: Set<string>;
showAll: boolean; showAll: boolean;
firstSpaceId: string; firstSpaceId: string;
/** Localized display strings for generated device names. */
loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string;
} }
export function entitiesByDevice(hass: any): Record<string, string[]> { export function entitiesByDevice(hass: any): Record<string, string[]> {
@@ -61,21 +63,21 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
return pool[0]?.eid; return pool[0]?.eid;
} }
/** Средний LQI zigbee по сущностям устройства (сенсоры *_linkquality/*_lqi либо атрибут). */ /** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
export function lqiFor(hass: any, entIds: string[]): number | null { export function lqiFor(hass: any, entIds: string[]): number | null {
const vals: number[] = []; const vals: number[] = [];
for (const eid of entIds) { for (const eid of entIds) {
const st = hass.states[eid]; const st = hass.states[eid];
if (!st) continue; if (!st) continue;
const unit = (st.attributes?.unit_of_measurement || '').toLowerCase(); const unit = (st.attributes?.unit_of_measurement || '').toLowerCase();
// 1) выделенный сенсор сигнала: Z2M *_linkquality, ZHA *_lqi, либо единицы «lqi» // 1) dedicated signal sensor: Z2M *_linkquality, ZHA *_lqi, or “lqi” units
if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') { if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') {
const v = parseFloat(st.state); const v = parseFloat(st.state);
if (!isNaN(v)) vals.push(v); if (!isNaN(v)) vals.push(v);
continue; continue;
} }
// 2) сигнал как АТРИБУТ на любой сущности устройства (Z2M linkquality / ZHA lqi) — // 2) signal as an ATTRIBUTE on any entity of the device (Z2M linkquality / ZHA lqi) —
// покрывает устройства, у которых отдельный сенсор сигнала отключён // covers devices whose dedicated signal sensor is disabled
const av = st.attributes?.linkquality ?? st.attributes?.lqi; const av = st.attributes?.linkquality ?? st.attributes?.lqi;
if (av != null) { if (av != null) {
const v = parseFloat(av); const v = parseFloat(av);
@@ -96,7 +98,7 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null; return null;
} }
/** Групповые световые сущности: HA light-group (platform=group) и Z2M-группы (device model=Group). */ /** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] { export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
if (!enabled) return []; if (!enabled) return [];
const res: { eid: string; name: string; area: string }[] = []; const res: { eid: string; name: string; area: string }[] = [];
@@ -129,9 +131,9 @@ function applyMarker(item: DevItem, m: Marker): void {
item.pdfs = m.pdfs || []; item.pdfs = m.pdfs || [];
} }
/** Курирование + группы света + маркеры (метаданные/перепривязка) + виртуальные. Гибрид. */ /** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] { export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId } = ctx; const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
const groupLights = settings.group_lights !== false; const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights); const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area)); const groupedAreas = new Set(groups.map((g) => g.area));
@@ -145,17 +147,17 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const seen: Record<string, number> = {}; const seen: Record<string, number> = {};
const rest: DevItem[] = []; const rest: DevItem[] = [];
// 1) авто-устройства HA (не занятые маркером, не скрытые) // 1) HA auto-discovered devices (not claimed by a marker, not hidden)
for (const dev of Object.values<any>(h.devices)) { for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id; const area = dev.area_id;
if (!area || !areaToSpace[area]) continue; if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue; if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id); const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue; if (marker && marker.hidden) continue;
const entIds = entsBy[dev.id] || []; const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds); const dom = domainOfDevice(h, dev, entIds);
// курирование (можно отключить переключателем «показать все») // curation (can be turned off with the “show all” toggle)
if (!showAll) { if (!showAll) {
if (excluded.has(dom)) continue; if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue; if (dev.model === 'Group') continue;
@@ -163,12 +165,12 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue; if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
if (dom === 'myheat' && dev.via_device_id) continue; if (dom === 'myheat' && dev.via_device_id) continue;
} }
const name = (dev.name_by_user || dev.name || 'без имени').trim(); const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
const key = name + '|' + area; const key = name + '|' + area;
let icon = iconFor(name, dev.model); let icon = iconFor(name, dev.model);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue; if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// дубли по «имя|зона» не скрываем, а нумеруем // duplicates by “name|zone” are numbered rather than hidden
seen[key] = (seen[key] || 0) + 1; seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name; const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
const item: DevItem = { const item: DevItem = {
@@ -188,14 +190,14 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
rest.push(item); rest.push(item);
} }
// 2) группы света (не занятые маркером) // 2) light groups (not claimed by a marker)
for (const g of groups) { for (const g of groups) {
if (!areaToSpace[g.area]) continue; if (!areaToSpace[g.area]) continue;
if (claimed.has('entity:' + g.eid)) continue; if (claimed.has('entity:' + g.eid)) continue;
rest.push({ rest.push({
id: 'lg_' + g.eid, id: 'lg_' + g.eid,
name: g.name, name: g.name,
model: 'группа света', model: loc('device.light_group'),
area: g.area, area: g.area,
space: areaToSpace[g.area], space: areaToSpace[g.area],
icon: 'mdi:lightbulb-group', icon: 'mdi:lightbulb-group',
@@ -207,7 +209,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}); });
} }
// 3) явные маркеры (перепривязка/метаданные/виртуальные) // 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) { for (const m of markers) {
if (m.hidden) continue; if (m.hidden) continue;
const [kind, ref] = m.binding.split(':'); const [kind, ref] = m.binding.split(':');
@@ -220,7 +222,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = { const item: DevItem = {
id: m.id, id: m.id,
name: dev?.name_by_user || dev?.name || 'устройство', name: dev?.name_by_user || dev?.name || loc('device.fallback'),
model: dev?.model || '', model: dev?.model || '',
area, area,
space, space,
@@ -253,12 +255,12 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
applyMarker(item, m); applyMarker(item, m);
rest.push(item); rest.push(item);
} else { } else {
// виртуальный // virtual
const area = m.area || ''; const area = m.area || '';
const space = m.space || (area && areaToSpace[area]) || firstSpaceId; const space = m.space || (area && areaToSpace[area]) || firstSpaceId;
const item: DevItem = { const item: DevItem = {
id: m.id, id: m.id,
name: m.name || 'виртуальное устройство', name: m.name || loc('device.virtual'),
model: m.model || '', model: m.model || '',
area, area,
space, space,
+32 -12
View File
@@ -1,14 +1,6 @@
/** Редактор конфигурации карточки (GUI в Lovelace). */ /** Card configuration editor (Lovelace GUI). */
import { LitElement, html, nothing } from 'lit'; import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
const LABELS: Record<string, string> = {
title: 'Заголовок',
default_floor: 'Пространство по умолчанию',
icon_size: 'Размер иконок, % ширины плана',
show_temperature: 'Показывать температуру',
live_states: 'Живые состояния (вкл/выкл, открыто…)',
show_signal: 'Показывать сигнал zigbee (LQI)',
};
class HouseplanCardEditor extends LitElement { class HouseplanCardEditor extends LitElement {
public hass?: any; public hass?: any;
@@ -26,7 +18,7 @@ class HouseplanCardEditor extends LitElement {
this._config = config; this._config = config;
} }
/** Пространства из серверного конфига интеграции — не хардкод. */ /** Spaces come from the integration's server config — never hard-coded. */
private async _loadSpaces(): Promise<void> { private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return; if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true; this._spacesLoading = true;
@@ -43,8 +35,13 @@ class HouseplanCardEditor extends LitElement {
} }
} }
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
private get _schema(): any[] { private get _schema(): any[] {
const spaces = this._spaces || []; const spaces = this._spaces || [];
const L = this._lang;
return [ return [
{ name: 'title', selector: { text: {} } }, { name: 'title', selector: { text: {} } },
spaces.length spaces.length
@@ -53,6 +50,19 @@ class HouseplanCardEditor extends LitElement {
selector: { select: { mode: 'dropdown', options: spaces } }, selector: { select: { mode: 'dropdown', options: spaces } },
} }
: { name: 'default_floor', selector: { text: {} } }, : { name: 'default_floor', selector: { text: {} } },
{
name: 'language',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: '', label: t(L, 'editor.lang_auto') },
{ value: 'en', label: t(L, 'editor.lang_en') },
{ value: 'ru', label: t(L, 'editor.lang_ru') },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } }, { name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } }, { name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } }, { name: 'live_states', selector: { boolean: {} } },
@@ -63,11 +73,21 @@ class HouseplanCardEditor extends LitElement {
protected render() { protected render() {
if (!this.hass || !this._config) return nothing; if (!this.hass || !this._config) return nothing;
this._loadSpaces(); this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
title: t(L, 'editor.title'),
default_floor: t(L, 'editor.default_floor'),
language: t(L, 'editor.language'),
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
show_signal: t(L, 'editor.show_signal'),
};
return html`<ha-form return html`<ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._config} .data=${this._config}
.schema=${this._schema} .schema=${this._schema}
.computeLabel=${(s: any) => LABELS[s.name] || s.name} .computeLabel=${(s: any) => labels[s.name] || s.name}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form>`; ></ha-form>`;
} }
+217 -210
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
/**
* Card UI localization. Language is resolved from the card config
* (`language: en|ru`) or, by default, from the HA user profile
* (hass.locale.language); anything that is not Russian falls back to English.
*/
export type Lang = 'en' | 'ru';
const en = {
'card.title': 'House plan',
'count.devices': '{n} dev.',
'empty.no_spaces': 'No spaces yet.',
'empty.add_first': 'Add the first space and upload a floor plan.',
'empty.install': 'Install the House Plan integration and add it in "Devices & services".',
'btn.add_space': 'Add space',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.close': 'Close',
'btn.delete': 'Delete',
'btn.remove': 'Remove',
'btn.edit': 'Edit',
'btn.open_in_ha': 'Open in HA',
'btn.reset': 'Reset',
'btn.attach': 'Attach…',
'btn.upload': 'Upload…',
'btn.replace': 'Replace…',
'btn.no_area': 'No area',
'title.zoom_in': 'Zoom in',
'title.zoom_out': 'Zoom out',
'title.zoom_reset': 'Reset zoom',
'title.add_device': 'Add a device to the plan',
'title.show_all': 'Show all area devices (no curation)',
'title.reset_layout': 'Reset icon positions to auto layout',
'title.markup': 'Room markup: grid, lines, outlines',
'title.configure_space': 'Configure space',
'title.add_space': 'Add space',
'title.markup_add': 'Add a room: connect grid dots with lines until the outline closes',
'title.markup_erase': 'Erase a line: click the line',
'title.markup_delroom': 'Delete a room: click inside the room',
'title.no_area_room': 'Decorative room without an HA area (e.g. a hallway)',
'title.choose_area': 'Select a Home Assistant area',
'title.need_plan': 'Upload a floor-plan image',
'markup.add': 'Add',
'markup.erase': 'Erase',
'markup.delete': 'Delete',
'markup.hint_points': 'points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one',
'markup.hint_start': 'click a grid dot to start the outline',
'tip.room': 'room — open the area',
'tip.lqi': 'average zigbee signal:',
'info.device_header': 'Device on the plan',
'info.model': 'Model',
'info.state': 'State',
'info.link': 'Link',
'info.manuals': 'Manuals',
'info.none': 'No additional information',
'marker.new_device': 'New device',
'marker.name_label': 'Name (shown on the plan)',
'marker.name_ph': 'Name',
'marker.binding_label': 'Bind to an HA device',
'marker.virtual_option': 'Virtual device (no binding)',
'marker.search_ph': 'Search device / group…',
'marker.nothing_found': 'nothing found',
'marker.room_label': 'Room',
'marker.room_override': ' (override placement)',
'marker.room_choose': '— select a room —',
'marker.room_auto': '— by device area (auto) —',
'marker.icon_label': 'Icon',
'marker.icon_ph': 'mdi:… (empty = auto)',
'marker.model_label': 'Model',
'marker.model_ph': 'e.g. Aqara T&H',
'marker.link_label': 'Link',
'marker.desc_label': 'Description',
'marker.desc_ph': 'Notes, specs…',
'marker.manuals_label': 'Manuals (PDF etc.)',
'marker.sub_device': 'device',
'marker.sub_z2m_group': ' · Z2M group',
'marker.sub_group': 'group',
'marker.sub_helper': 'helper',
'space.new': 'New space',
'space.header': 'Space',
'space.title_label': 'Title',
'space.title_ph': 'e.g. Garage',
'space.plan_label': 'Floor plan (background)',
'space.no_plan': 'no plan image',
'space.plan_alt': 'plan',
'room.new': 'New room',
'room.name_label': 'Display name',
'room.name_ph': 'e.g. Terrace',
'room.area_label': 'Home Assistant area (unassigned)',
'room.no_area_option': '— no area —',
'room.default_name': 'Room',
'device.unnamed': 'unnamed',
'device.light_group': 'light group',
'device.fallback': 'device',
'device.virtual': 'virtual device',
'confirm.reset_layout': 'Reset all icon positions to the auto layout?',
'confirm.delete_room': 'Delete room "{name}"?',
'confirm.remove_marker': 'Remove "{name}" from the plan?',
'confirm.delete_space': 'Delete space "{title}" with all its rooms and markup?',
'toast.pos_save_failed': 'Failed to save position: {err}',
'toast.no_entity': 'The device has no suitable entity',
'toast.markup_needs_server': 'Markup is available after the config is moved to the server',
'toast.conflict': 'Config was changed in another window — data refreshed, repeat your last action',
'toast.cfg_save_failed': 'Failed to save config: {err}',
'toast.room_saved': 'Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.',
'toast.room_saved_no_area': 'Room saved ({n}, no area). Outline the next one or exit markup.',
'toast.marker_needs_server': 'Device editing is available after the config is moved to the server',
'toast.virtual_name_required': 'Enter a name for the virtual device',
'toast.marker_saved': 'Device saved',
'toast.marker_removed': 'Device removed from the plan',
'toast.integration_missing': 'The House Plan integration is not installed — management unavailable',
'toast.plan_formats': 'Supported formats: SVG, PNG, JPG, WebP',
'toast.plan_required': 'Upload a floor plan — it is required',
'toast.space_added_onboard': 'Space added. Outline the rooms: click grid dots and close the contour.',
'toast.space_added': 'Space added',
'toast.space_saved': 'Space saved',
'toast.space_deleted': 'Space deleted',
'toast.delete_failed': 'Delete failed: {err}',
'toast.error': 'Error: {err}',
'toast.file_failed': 'File "{name}" was not uploaded: {err}',
'toast.files_attached': 'Files attached: {n}',
'err.unknown': 'unknown error',
'err.code': 'code {code}',
'err.too_large': 'file larger than {mb} MB',
'err.bad_ext': 'unsupported type (PDF/image expected)',
'err.unauthorized': 'administrator rights required',
'editor.title': 'Title',
'editor.default_floor': 'Default space',
'editor.icon_size': 'Icon size, % of plan width',
'editor.show_temperature': 'Show temperature',
'editor.live_states': 'Live states (on/off, open…)',
'editor.show_signal': 'Show zigbee signal (LQI)',
'editor.language': 'Interface language',
'editor.lang_auto': 'Auto (HA profile)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
type Key = keyof typeof en;
const ru: Record<Key, string> = {
'card.title': 'План дома',
'count.devices': '{n} устр.',
'empty.no_spaces': 'Пространств пока нет.',
'empty.add_first': 'Добавьте первое пространство и загрузите план этажа.',
'empty.install': 'Установите интеграцию House Plan и добавьте запись в «Устройства и службы».',
'btn.add_space': 'Добавить пространство',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.close': 'Закрыть',
'btn.delete': 'Удалить',
'btn.remove': 'Убрать',
'btn.edit': 'Редактировать',
'btn.open_in_ha': 'Открыть в HA',
'btn.reset': 'Сброс',
'btn.attach': 'Прикрепить…',
'btn.upload': 'Загрузить…',
'btn.replace': 'Заменить…',
'btn.no_area': 'Без зоны',
'title.zoom_in': 'Приблизить',
'title.zoom_out': 'Отдалить',
'title.zoom_reset': 'Сбросить масштаб',
'title.add_device': 'Добавить устройство на план',
'title.show_all': 'Показывать все устройства зоны (без курирования)',
'title.reset_layout': 'Сбросить позиции значков к авто-раскладке',
'title.markup': 'Разметка комнат: сетка, линии, контуры',
'title.configure_space': 'Настроить пространство',
'title.add_space': 'Добавить пространство',
'title.markup_add': 'Добавить комнату: соединяйте точки сетки линиями до замкнутого контура',
'title.markup_erase': 'Стереть линию: клик по линии',
'title.markup_delroom': 'Удалить комнату: клик внутри комнаты',
'title.no_area_room': 'Декоративная комната без привязки к зоне (например, холл)',
'title.choose_area': 'Выберите зону Home Assistant',
'title.need_plan': 'Загрузите подложку (план этажа)',
'markup.add': 'Добавить',
'markup.erase': 'Стереть',
'markup.delete': 'Удалить',
'markup.hint_points': 'точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой',
'markup.hint_start': 'кликните точку сетки, чтобы начать контур',
'tip.room': 'комната — открыть зону',
'tip.lqi': 'средний сигнал zigbee:',
'info.device_header': 'Устройство на плане',
'info.model': 'Модель',
'info.state': 'Состояние',
'info.link': 'Ссылка',
'info.manuals': 'Инструкции',
'info.none': 'Нет дополнительной информации',
'marker.new_device': 'Новое устройство',
'marker.name_label': 'Имя (отображается на плане)',
'marker.name_ph': 'Название',
'marker.binding_label': 'Привязка к устройству HA',
'marker.virtual_option': 'Виртуальное устройство (без привязки)',
'marker.search_ph': 'Поиск устройства / группы…',
'marker.nothing_found': 'ничего не найдено',
'marker.room_label': 'Комната',
'marker.room_override': ' (переопределить размещение)',
'marker.room_choose': '— выберите комнату —',
'marker.room_auto': '— по зоне устройства (авто) —',
'marker.icon_label': 'Иконка',
'marker.icon_ph': 'mdi:… (пусто = авто)',
'marker.model_label': 'Модель',
'marker.model_ph': 'напр. Aqara T&H',
'marker.link_label': 'Ссылка',
'marker.desc_label': 'Описание',
'marker.desc_ph': 'Заметки, характеристики…',
'marker.manuals_label': 'Инструкции (PDF и т.п.)',
'marker.sub_device': 'устройство',
'marker.sub_z2m_group': ' · Z2M-группа',
'marker.sub_group': 'группа',
'marker.sub_helper': 'хелпер',
'space.new': 'Новое пространство',
'space.header': 'Пространство',
'space.title_label': 'Название',
'space.title_ph': 'Например: Гараж',
'space.plan_label': 'Подложка (план)',
'space.no_plan': 'нет подложки',
'space.plan_alt': 'план',
'room.new': 'Новая комната',
'room.name_label': 'Отображаемое имя',
'room.name_ph': 'Например: Терраса',
'room.area_label': 'Зона Home Assistant (свободные)',
'room.no_area_option': '— без зоны —',
'room.default_name': 'Комната',
'device.unnamed': 'без имени',
'device.light_group': 'группа света',
'device.fallback': 'устройство',
'device.virtual': 'виртуальное устройство',
'confirm.reset_layout': 'Сбросить позиции всех иконок к авто-раскладке?',
'confirm.delete_room': 'Удалить комнату «{name}»?',
'confirm.remove_marker': 'Убрать «{name}» с плана?',
'confirm.delete_space': 'Удалить пространство «{title}» со всеми комнатами и разметкой?',
'toast.pos_save_failed': 'Не удалось сохранить позицию: {err}',
'toast.no_entity': 'У устройства нет подходящей сущности',
'toast.markup_needs_server': 'Разметка доступна после переноса конфига на сервер',
'toast.conflict': 'Конфиг изменён в другом окне — данные обновлены, повторите последнее действие',
'toast.cfg_save_failed': 'Не удалось сохранить конфиг: {err}',
'toast.room_saved': 'Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.',
'toast.room_saved_no_area': 'Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.',
'toast.marker_needs_server': 'Редактирование устройств доступно после переноса конфига на сервер',
'toast.virtual_name_required': 'Укажите имя виртуального устройства',
'toast.marker_saved': 'Устройство сохранено',
'toast.marker_removed': 'Устройство убрано с плана',
'toast.integration_missing': 'Интеграция House Plan не установлена — управление недоступно',
'toast.plan_formats': 'Поддерживаются SVG, PNG, JPG, WebP',
'toast.plan_required': 'Загрузите подложку — план этажа обязателен',
'toast.space_added_onboard': 'Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.',
'toast.space_added': 'Пространство добавлено',
'toast.space_saved': 'Пространство сохранено',
'toast.space_deleted': 'Пространство удалено',
'toast.delete_failed': 'Ошибка удаления: {err}',
'toast.error': 'Ошибка: {err}',
'toast.file_failed': 'Файл «{name}» не загружен: {err}',
'toast.files_attached': 'Прикреплено файлов: {n}',
'err.unknown': 'неизвестная ошибка',
'err.code': 'код {code}',
'err.too_large': 'файл больше {mb} МБ',
'err.bad_ext': 'недопустимый тип (нужен PDF/изображение)',
'err.unauthorized': 'нужны права администратора',
'editor.title': 'Заголовок',
'editor.default_floor': 'Пространство по умолчанию',
'editor.icon_size': 'Размер иконок, % ширины плана',
'editor.show_temperature': 'Показывать температуру',
'editor.live_states': 'Живые состояния (вкл/выкл, открыто…)',
'editor.show_signal': 'Показывать сигнал zigbee (LQI)',
'editor.language': 'Язык интерфейса',
'editor.lang_auto': 'Авто (профиль HA)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
const DICTS: Record<Lang, Record<Key, string>> = { en, ru };
/** Resolve the UI language: explicit config option wins, then the HA profile. */
export function langOf(hass: any, configLang?: string | null): Lang {
if (configLang === 'ru' || configLang === 'en') return configLang;
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
return l.startsWith('ru') ? 'ru' : 'en';
}
/** Translate a key with optional {placeholder} substitution. */
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
let s = DICTS[lang][key] ?? en[key] ?? key;
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replace('{' + k + '}', String(v));
return s;
}
export type { Key as I18nKey };
+13 -13
View File
@@ -1,30 +1,30 @@
/** /**
* Чистые функции без зависимостей от Lit/DOM легко покрываются юнит-тестами. * Pure functions with no Lit/DOM dependencies easy to cover with unit tests.
*/ */
/** Цвет LQI zigbee: ≤40 — красный, ≥180 — зелёный, между — hsl-градиент. */ /** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string { export function lqiColor(lqi: number): string {
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120)); const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
return `hsl(${Math.round(hue)}, 85%, 55%)`; return `hsl(${Math.round(hue)}, 85%, 55%)`;
} }
/** Привязать координату к ближайшему узлу сетки с шагом pitch. */ /** Snap a coordinate to the nearest grid node with step pitch. */
export function snapToGrid(v: number, pitch: number): number { export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch; return Math.round(v / pitch) * pitch;
} }
/** Канонический ключ отрезка (независим от направления). */ /** Canonical key of a segment (independent of direction). */
export function segKey(a: number[], b: number[]): string { export function segKey(a: number[], b: number[]): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a]; const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`; return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
} }
/** Совпадение точек с допуском. */ /** Point equality within a tolerance. */
export function samePoint(a: number[], b: number[], eps = 0.001): boolean { export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps; return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
} }
/** Точка внутри полигона (ray casting). */ /** Point inside a polygon (ray casting). */
export function pointInPolygon(p: number[], poly: number[][]): boolean { export function pointInPolygon(p: number[], poly: number[][]): boolean {
let inside = false; let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
@@ -36,8 +36,8 @@ export function pointInPolygon(p: number[], poly: number[][]): boolean {
} }
/** /**
* id маркера по привязке: device device_id, entity 'lg_'+entity_id, * Marker id by binding: device device_id, entity 'lg_'+entity_id,
* virtual переданный existing (если это уже v_-маркер) либо новый через newId(). * virtual the passed-in existing (if it is already a v_ marker) or a new one via newId().
*/ */
export function markerIdForBinding( export function markerIdForBinding(
binding: string, binding: string,
@@ -50,13 +50,13 @@ export function markerIdForBinding(
return existingId && existingId.startsWith('v_') ? existingId : newId(); return existingId && existingId.startsWith('v_') ? existingId : newId();
} }
/** Средний LQI по набору значений (или null). */ /** Average LQI over a set of values (or null). */
export function averageLqi(values: number[]): number | null { export function averageLqi(values: number[]): number | null {
if (!values.length) return null; if (!values.length) return null;
return Math.round(values.reduce((a, b) => a + b, 0) / values.length); return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
} }
/** Прямоугольник «contain» с заданным аспектом (w/h), вмещающий весь vb [x,y,w,h]. */ /** “Contain” rectangle with the given aspect (w/h) that fits the whole vb [x,y,w,h]. */
export function fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } { export function fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
const planA = vb[2] / vb[3]; const planA = vb[2] / vb[3];
if (aspect > planA) { if (aspect > planA) {
@@ -67,7 +67,7 @@ export function fitView(vb: number[], aspect: number): { x: number; y: number; w
return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h }; return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
} }
/** Расталкивание точек: не ближе minDist друг к другу, в пределах прямоугольника b с отступом pad. Мутирует pts. */ /** Push points apart: no closer than minDist to each other, within rectangle b with padding pad. Mutates pts. */
export function declump( export function declump(
pts: { x: number; y: number }[], pts: { x: number; y: number }[],
b: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number },
@@ -100,8 +100,8 @@ export function declump(
} }
/** /**
* Безопасный URL для <a href>: допускаются только http(s) и относительные пути. * Safe URL for <a href>: only http(s) and relative paths are allowed.
* Отсекает javascript:, data: и прочие опасные схемы (XSS через конфиг). * Rejects javascript:, data: and other dangerous schemes (XSS via config).
*/ */
export function safeUrl(url: string | null | undefined): string | null { export function safeUrl(url: string | null | undefined): string | null {
if (!url) return null; if (!url) return null;
+4 -4
View File
@@ -1,8 +1,8 @@
/** /**
* Правила прототипа перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.24.5). * Prototype rules ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.24.5).
*/ */
/** Интеграции-домены, чьи устройства скрываются (курирование). */ /** Integration domains whose devices are hidden (curation). */
export const EXCLUDED_DOMAINS = new Set([ export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app', 'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda', 'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
@@ -38,7 +38,7 @@ const ICON_RULES: Array<[RegExp, string]> = [
[/slzb|координат|zigbee/, 'mdi:zigbee'], [/slzb|координат|zigbee/, 'mdi:zigbee'],
]; ];
/** Подбор MDI-иконки по имени/модели устройства. */ /** Pick an MDI icon by device name/model. */
export function iconFor(name?: string, model?: string): string { export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase(); const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) { for (const [pat, icon] of ICON_RULES) {
@@ -47,7 +47,7 @@ export function iconFor(name?: string, model?: string): string {
return 'mdi:chip'; return 'mdi:chip';
} }
/** Приоритет доменов для выбора «первичной» сущности устройства (more-info). */ /** Domain priority for picking the device's “primary” entity (more-info). */
export const DOMAIN_PRIORITY = [ export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan', 'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater', 'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
+4 -4
View File
@@ -1,4 +1,4 @@
/** Стили карточки House Plan (вынесены из компонента). */ /** Styles of the House Plan card (extracted from the component). */
import { css } from 'lit'; import { css } from 'lit';
export const cardStyles = css` export const cardStyles = css`
@@ -12,7 +12,7 @@ export const cardStyles = css`
--hp-open: #ff9f43; --hp-open: #ff9f43;
} }
ha-card { ha-card {
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */ overflow: visible; /* overflow:hidden breaks position:sticky on the header */
} }
.empty { .empty {
padding: 40px 24px; padding: 40px 24px;
@@ -139,7 +139,7 @@ export const cardStyles = css`
width: 100%; width: 100%;
container-type: inline-size; container-type: inline-size;
overflow: hidden; overflow: hidden;
touch-action: none; /* свои жесты pinch/pan */ touch-action: none; /* custom pinch/pan gestures */
background: var(--ha-card-background, var(--card-background-color, #111)); background: var(--ha-card-background, var(--card-background-color, #111));
} }
.zoomwrap { .zoomwrap {
@@ -220,7 +220,7 @@ export const cardStyles = css`
pointer-events: none; pointer-events: none;
} }
.stage.markup .devlayer { .stage.markup .devlayer {
display: none; /* в разметке иконки не мешают */ display: none; /* in markup mode icons must not get in the way */
} }
.room.outlined { .room.outlined {
stroke: rgba(62, 166, 255, 0.55); stroke: rgba(62, 166, 255, 0.55);
+5 -4
View File
@@ -1,4 +1,4 @@
/** Общие типы карточки House Plan. */ /** Shared types of the House Plan card. */
export interface RoomCfg { export interface RoomCfg {
id?: string; id?: string;
@@ -8,7 +8,7 @@ export interface RoomCfg {
y?: number; y?: number;
w?: number; w?: number;
h?: number; h?: number;
poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг) poly?: number[][]; // polygon in render units (model) / normalized (config)
} }
export interface SpaceModel { export interface SpaceModel {
@@ -24,7 +24,7 @@ export interface PdfRef {
url: string; url: string;
} }
/** Маркер конфига: правит/дополняет авто-устройство ИЛИ описывает ручной/виртуальный значок. */ /** Config marker: edits/augments an auto-discovered device OR describes a manual/virtual icon. */
export interface Marker { export interface Marker {
id: string; id: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
@@ -56,7 +56,7 @@ export interface DevItem {
primary?: string; primary?: string;
temp?: number | null; temp?: number | null;
virtual?: boolean; virtual?: boolean;
marker?: Marker; // связанный маркер конфига (метаданные, оверрайды) marker?: Marker; // linked config marker (metadata, overrides)
bindingKind?: 'device' | 'entity' | 'virtual'; bindingKind?: 'device' | 'entity' | 'virtual';
bindingRef?: string; // device_id / entity_id bindingRef?: string; // device_id / entity_id
link?: string | null; link?: string | null;
@@ -72,4 +72,5 @@ export interface CardConfig {
show_temperature?: boolean; show_temperature?: boolean;
live_states?: boolean; live_states?: boolean;
show_signal?: boolean; show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
} }
+21 -20
View File
@@ -6,7 +6,7 @@ import {
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { iconFor } from '../test-build/rules.js'; import { iconFor } from '../test-build/rules.js';
test('lqiColor: границы и середина', () => { test('lqiColor: boundaries and midpoint', () => {
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)'); assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)'); assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)');
assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)'); assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)');
@@ -20,24 +20,24 @@ test('snapToGrid', () => {
assert.equal(snapToGrid(16, 10), 20); assert.equal(snapToGrid(16, 10), 20);
}); });
test('segKey: направление не влияет', () => { test('segKey: direction does not matter', () => {
assert.equal(segKey([0, 0], [10, 5]), segKey([10, 5], [0, 0])); assert.equal(segKey([0, 0], [10, 5]), segKey([10, 5], [0, 0]));
assert.notEqual(segKey([0, 0], [10, 5]), segKey([0, 0], [10, 6])); assert.notEqual(segKey([0, 0], [10, 5]), segKey([0, 0], [10, 6]));
}); });
test('samePoint с допуском', () => { test('samePoint with tolerance', () => {
assert.ok(samePoint([1, 1], [1.0005, 0.9995])); assert.ok(samePoint([1, 1], [1.0005, 0.9995]));
assert.ok(!samePoint([1, 1], [1.5, 1])); assert.ok(!samePoint([1, 1], [1.5, 1]));
}); });
test('pointInPolygon: квадрат', () => { test('pointInPolygon: square', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]]; const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
assert.ok(pointInPolygon([5, 5], sq)); assert.ok(pointInPolygon([5, 5], sq));
assert.ok(!pointInPolygon([15, 5], sq)); assert.ok(!pointInPolygon([15, 5], sq));
assert.ok(!pointInPolygon([-1, -1], sq)); assert.ok(!pointInPolygon([-1, -1], sq));
}); });
test('pointInPolygon: L-образный полигон', () => { test('pointInPolygon: L-shaped polygon', () => {
const L = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]]; const L = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]];
assert.ok(pointInPolygon([1, 5], L)); assert.ok(pointInPolygon([1, 5], L));
assert.ok(pointInPolygon([5, 1], L)); assert.ok(pointInPolygon([5, 1], L));
@@ -60,60 +60,61 @@ test('averageLqi', () => {
assert.equal(averageLqi([1, 2, 2]), 2); assert.equal(averageLqi([1, 2, 2]), 2);
}); });
test('iconFor: ключевые правила', () => { test('iconFor: key rules', () => {
// Russian device names below are intentional: iconFor rules match Russian names (see src/rules.ts).
assert.equal(iconFor('Датчик протечки кухня', 'HOBEIAN'), 'mdi:water-alert'); assert.equal(iconFor('Датчик протечки кухня', 'HOBEIAN'), 'mdi:water-alert');
assert.equal(iconFor('Замок Терраса', 'TTLock'), 'mdi:lock'); assert.equal(iconFor('Замок Терраса', 'TTLock'), 'mdi:lock');
assert.equal(iconFor('Настенная лампа 1', 'Yandex Bulb'), 'mdi:lightbulb'); assert.equal(iconFor('Настенная лампа 1', 'Yandex Bulb'), 'mdi:lightbulb');
assert.equal(iconFor('Ворота', 'Tuya Garage'), 'mdi:garage-variant'); assert.equal(iconFor('Ворота', 'Tuya Garage'), 'mdi:garage-variant');
assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator'); assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator');
assert.equal(iconFor('Неизвестное', 'XYZ'), 'mdi:chip'); assert.equal(iconFor('Unknown gadget', 'XYZ'), 'mdi:chip');
}); });
test('fitView: портретный план в широкой сцене — по бокам поля, весь план внутри', () => { test('fitView: portrait plan in a wide scene — margins on the sides, whole plan inside', () => {
// vb 100x200 (аспект 0.5), сцена аспект 2 → view шире плана, высота = 200 // vb 100x200 (aspect 0.5), scene aspect 2 → view wider than the plan, height = 200
const v = fitView([0, 0, 100, 200], 2); const v = fitView([0, 0, 100, 200], 2);
assert.equal(v.h, 200); assert.equal(v.h, 200);
assert.equal(v.w, 400); // 200*2 assert.equal(v.w, 400); // 200*2
assert.equal(v.x, -150); // (100-400)/2 центрирование assert.equal(v.x, -150); // (100-400)/2 centering
assert.equal(v.y, 0); assert.equal(v.y, 0);
// весь план внутри view // the whole plan is inside the view
assert.ok(v.x <= 0 && v.x + v.w >= 100 && v.y <= 0 && v.y + v.h >= 200); assert.ok(v.x <= 0 && v.x + v.w >= 100 && v.y <= 0 && v.y + v.h >= 200);
}); });
test('fitView: аспект сцены совпадает с планом — view == vb', () => { test('fitView: scene aspect matches the plan — view == vb', () => {
const v = fitView([10, 20, 300, 150], 2); // планA = 2 == аспект const v = fitView([10, 20, 300, 150], 2); // plan aspect = 2 == scene aspect
assert.equal(v.x, 10); assert.equal(v.y, 20); assert.equal(v.w, 300); assert.equal(v.h, 150); assert.equal(v.x, 10); assert.equal(v.y, 20); assert.equal(v.w, 300); assert.equal(v.h, 150);
}); });
test('declump: близкие точки расходятся не ближе minDist и остаются в границах', () => { test('declump: close points spread apart no closer than minDist and stay within bounds', () => {
const b = { x: 0, y: 0, w: 100, h: 100 }; const b = { x: 0, y: 0, w: 100, h: 100 };
const pts = [ { x: 50, y: 50 }, { x: 51, y: 50 }, { x: 50, y: 51 } ]; const pts = [ { x: 50, y: 50 }, { x: 51, y: 50 }, { x: 50, y: 51 } ];
declump(pts, b, 20, 5); declump(pts, b, 20, 5);
// все пары не ближе ~minDist (с допуском на кламп к границам) // all pairs no closer than ~minDist (with tolerance for clamping to the bounds)
for (let i = 0; i < pts.length; i++) for (let i = 0; i < pts.length; i++)
for (let j = i + 1; j < pts.length; j++) { for (let j = i + 1; j < pts.length; j++) {
const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y); const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y);
assert.ok(d > 12, `пара ${i},${j} слишком близко: ${d}`); assert.ok(d > 12, `pair ${i},${j} too close: ${d}`);
} }
// в границах [5..95] // within the bounds [5..95]
for (const q of pts) { for (const q of pts) {
assert.ok(q.x >= 5 && q.x <= 95 && q.y >= 5 && q.y <= 95); assert.ok(q.x >= 5 && q.x <= 95 && q.y >= 5 && q.y <= 95);
} }
}); });
test('declump: одна точка не двигается', () => { test('declump: a single point does not move', () => {
const pts = [{ x: 30, y: 40 }]; const pts = [{ x: 30, y: 40 }];
declump(pts, { x: 0, y: 0, w: 100, h: 100 }, 20, 5); declump(pts, { x: 0, y: 0, w: 100, h: 100 }, 20, 5);
assert.deepEqual(pts, [{ x: 30, y: 40 }]); assert.deepEqual(pts, [{ x: 30, y: 40 }]);
}); });
test('averageLqi: пусто → null, иначе округлённое среднее', () => { test('averageLqi: empty → null, otherwise the rounded average', () => {
assert.equal(averageLqi([]), null); assert.equal(averageLqi([]), null);
assert.equal(averageLqi([100, 200]), 150); assert.equal(averageLqi([100, 200]), 150);
assert.equal(averageLqi([1, 2, 2]), 2); assert.equal(averageLqi([1, 2, 2]), 2);
}); });
test('safeUrl: допускает http(s) и относительные, режет опасные схемы', () => { test('safeUrl: allows http(s) and relative paths, cuts dangerous schemes', () => {
assert.equal(safeUrl('https://example.com/a?b=1'), 'https://example.com/a?b=1'); assert.equal(safeUrl('https://example.com/a?b=1'), 'https://example.com/a?b=1');
assert.equal(safeUrl('http://x.ru'), 'http://x.ru'); assert.equal(safeUrl('http://x.ru'), 'http://x.ru');
assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf'); assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf');
+7 -7
View File
@@ -1,5 +1,5 @@
"""Юнит-тесты чистой валидации House Plan (загружаем validation.py по пути, """Unit tests for the pure House Plan validation (validation.py is loaded by path,
без импорта пакета HA-интеграции).""" without importing the HA integration package)."""
import importlib.util import importlib.util
import os import os
@@ -17,7 +17,7 @@ _spec.loader.exec_module(v)
def test_sanitize_marker_id(): def test_sanitize_marker_id():
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd" assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
assert v.sanitize_marker_id("..") == "misc" # чистый traversal → misc assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
assert v.sanitize_marker_id(".") == "misc" assert v.sanitize_marker_id(".") == "misc"
assert v.sanitize_marker_id("") == "misc" assert v.sanitize_marker_id("") == "misc"
assert len(v.sanitize_marker_id("a" * 200)) == 64 assert len(v.sanitize_marker_id("a" * 200)) == 64
@@ -25,8 +25,8 @@ def test_sanitize_marker_id():
def test_sanitize_filename_strips_path(): def test_sanitize_filename_strips_path():
assert v.sanitize_filename("/a/b/c/manual.pdf") == "manual.pdf" assert v.sanitize_filename("/a/b/c/manual.pdf") == "manual.pdf"
assert v.sanitize_filename("..\\..\\evil.pdf") == "evil.pdf" # обратные слэши = путь assert v.sanitize_filename("..\\..\\evil.pdf") == "evil.pdf" # backslashes = a path
assert v.sanitize_filename("...hidden.pdf") == "hidden.pdf" # ведущие точки убраны assert v.sanitize_filename("...hidden.pdf") == "hidden.pdf" # leading dots stripped
def test_file_ext(): def test_file_ext():
@@ -79,9 +79,9 @@ def test_config_schema_defaults_and_extra():
def test_config_schema_full_roundtrip(): def test_config_schema_full_roundtrip():
cfg = { cfg = {
"spaces": [{ "spaces": [{
"id": "f1", "title": "1 этаж", "plan_url": "/p/f1.svg", "id": "f1", "title": "Floor 1", "plan_url": "/p/f1.svg",
"aspect": 0.8, "view_box": [0, 0, 1, 1], "aspect": 0.8, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r1", "name": "Зал", "area": "hall", "rooms": [{"id": "r1", "name": "Hall", "area": "hall",
"poly": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}], "poly": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}],
"segments": [[0, 0, 0.5, 0]], "segments": [[0, 0, 0.5, 0]],
}], }],