Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] e2ff2acb7a dev-build: 674e589ad8
Source: 674e589ad8
Issue: #657
User-Visible: no
2026-09-29 06:30:42 +00:00
86 changed files with 5104 additions and 8659 deletions
-19
View File
@@ -1,19 +0,0 @@
name: Release
on:
release:
types: [published]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
with:
files: dist/houseplan-card.js
-45
View File
@@ -1,45 +0,0 @@
name: Validate
on:
push:
pull_request:
schedule:
- cron: "0 4 * * 1"
jobs:
hacs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: HACS validation
uses: hacs/action@main
with:
category: integration
hassfest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install pytest voluptuous
- name: Backend unit tests
run: python -m pytest tests_backend/ -q
-6
View File
@@ -1,6 +0,0 @@
node_modules/
tsout/
test-build/
*.log
__pycache__/
.pytest_cache/
+7
View File
@@ -0,0 +1,7 @@
{
"schema": 1,
"source": "674e589ad841c6580f31d1a495bb207f13e6931e",
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
"files": 30,
"builtAt": "2026-09-29T06:30:42.564Z"
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 JB (justbusiness)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-157
View File
@@ -1,157 +0,0 @@
# 🏠 House Plan — an interactive house plan for Home Assistant
**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.**
![Interactive house plan with devices](docs/images/01-plan.png)
🇷🇺 [Документация на русском](README.ru.md)
---
## What it is and why
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.
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
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) |
|---|---|---|
| **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 |
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
| **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.
- **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.
- **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.
- **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
### Via HACS (recommended)
1. Open **HACS → menu (⋮) → Custom repositories**.
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
The card is registered automatically — no need to add a Lovelace resource manually.
### Manually
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
2. Restart Home Assistant.
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
### Adding a plan screen
Create a new dashboard tab (a "Panel" view works best) and add the card:
```yaml
type: custom:houseplan-card
title: House plan
```
Nothing else needs to be specified — everything else is configured right on the screen.
---
## How to use
### Step 1. Add a space (floor)
On first open the plan is still empty — House Plan immediately offers to create the first space.
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
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.
![Space creation dialog](docs/images/03-space-dialog.png)
> 💡 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.
### 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.
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.
![Marking up a room and saving it](docs/images/05-room-dialog.png)
### 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.
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.
![Device card on click](docs/images/08-info.png)
### 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.
![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png)
### 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.
![Dragging icons — available at all times](docs/images/06-edit.png)
### Step 6. Adding your own devices manually
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**.
![Adding a device manually](docs/images/07-marker-dialog.png)
---
## Uninstalling
1. Remove the card (or the tab with the plan) from the dashboard.
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
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
**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.
**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.
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
---
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
-157
View File
@@ -1,157 +0,0 @@
# 🏠 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>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

-129
View File
@@ -1,129 +0,0 @@
"""House Plan: server-side house plan configuration + serving the Lovelace card."""
from __future__ import annotations
import logging
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from . import websocket_api as hp_ws
from .const import (
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_VERSION,
VERSION,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register WS commands, the HTTP upload and the stores on startup."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
hp_ws.async_register(hass)
from .http_api import HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config entry: static frontend and plan files + auto-registration of the JS."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["entry"] = entry
entry.async_on_unload(entry.add_update_listener(_update_listener))
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
files_path = Path(hass.config.path(FILES_DIR))
await hass.async_add_executor_job(
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
)
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_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)
except ImportError: # older HA versions
if card_path.exists():
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(FILES_URL, str(files_path), cache_headers=True)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True
# 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
# app). If the resource registry is unavailable (Lovelace YAML mode, older versions) —
# fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url):
add_extra_js_url(hass, module_url)
return True
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry.
Returns True on success. Writes idempotently: if a resource with our path already exists —
update the URL on a version change; if absent — create it. Any-except → False (fallback to JS).
"""
try:
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
if resources is None:
return False
# the resource registry must be loaded
if hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load()
resources.loaded = True
elif hasattr(resources, "async_get_info"):
await resources.async_get_info()
# only storage mode allows creating items
if not hasattr(resources, "async_create_item"):
return False
base = FRONTEND_URL
existing = [
item for item in resources.async_items()
if str(item.get("url", "")).split("?", 1)[0] == base
]
if existing:
item = existing[0]
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
await resources.async_update_item(item["id"], {"url": module_url})
return True
await resources.async_create_item({"res_type": "module", "url": module_url})
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True
except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Failed to register the Lovelace resource (%s), falling back to extra_module_url", err)
return False
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass.data[DOMAIN]["entry"] = entry
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop("entry", None)
return True
@@ -1,41 +0,0 @@
"""Config flow: a single entry with no parameters."""
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_ADMIN_ONLY, DOMAIN
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""One-step setup."""
VERSION = 1
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=False): bool}),
)
@staticmethod
def async_get_options_flow(config_entry):
return HouseplanOptionsFlow()
class HouseplanOptionsFlow(config_entries.OptionsFlow):
"""Option: layout editing by administrators only."""
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
)
-19
View File
@@ -1,19 +0,0 @@
"""Constants of the House Plan integration."""
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.11.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {},
}
@@ -0,0 +1,438 @@
{
"schema": 1,
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300323,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301845,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2325,
"lazyFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/fr-CpDKyK4Z.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/pdf-export-DL7VmwpB.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
],
"lazyGzipBytes": 456389,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"lazyEditorGzipBytes": 238192,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"lazyOnboardingGzipBytes": 27950,
"lazyLocaleFiles": [
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/fr-CpDKyK4Z.js"
],
"lazyLocaleGzipBytes": 56330,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-BxXtKC5L.js"
],
"lazyIsometricGzipBytes": 17885,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js"
],
"lazyFurnitureArtGzipBytes": 16940,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/pdf-export-DL7VmwpB.js"
],
"lazyPdfGzipBytes": 126938,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/settings-de-DfJ7EKF9.js",
"houseplan-assets/settings-fr-Bl79E-6s.js",
"houseplan-assets/settings-ru-CkNFmChi.js",
"houseplan-assets/support-de-DZoi-fxk.js",
"houseplan-assets/support-fr-Daosy89Q.js",
"houseplan-assets/support-ru-e8-BrqtH.js",
"houseplan-assets/topology-de-Cs36ouVE.js",
"houseplan-assets/topology-fr-B8yoJGvc.js",
"houseplan-assets/topology-ru-BRKShmrQ.js"
],
"lazyNamespaceLocaleGzipBytes": 21057,
"files": [
{
"path": "houseplan-assets/backdrop-pick-WDIF6iao.js",
"sha256": "5f6dcedf274594075d8acd31295d8c23f6d1408c890c086825db6bcbaa6c9b21",
"rawBytes": 54017,
"gzipBytes": 17576,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-DfJ7EKF9.js",
"houseplan-assets/settings-fr-Bl79E-6s.js",
"houseplan-assets/settings-ru-CkNFmChi.js"
]
},
{
"path": "houseplan-assets/de-E17v3gXt.js",
"sha256": "ed10d6c5e7a82b275c9422be67c2213219a75dba9aab0ddb3e98813249cbd1d0",
"rawBytes": 94196,
"gzipBytes": 28393,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CbeYCXUv.js",
"sha256": "75887638c4d76040651a3cf52c56de10dc19e0cbc7583ee169d9b20cc5987788",
"rawBytes": 3833,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-v5DlO12L.js",
"sha256": "cdab9e5e612be9b98c49193a2a6287080c13f62aaefa9a28b69bac94dd934012",
"rawBytes": 25600,
"gzipBytes": 5056,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-CpDKyK4Z.js",
"sha256": "ee9fe5934eea69e549ff48d2e3fdabd7c20e0d9906a6f4623314f6eea48bf269",
"rawBytes": 96627,
"gzipBytes": 27937,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"sha256": "f668d3c0a47a53e096f26728187b1666fbb6b421c1baa2ff1a649fa1ba5340d5",
"rawBytes": 52035,
"gzipBytes": 16940,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BYwTiVnu.js",
"sha256": "055b6f8ba3a472be163a15ef6e2348727b5b03e3660b9362f3aed21d7be90426",
"rawBytes": 1059790,
"gzipBytes": 299520,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/fr-CpDKyK4Z.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"houseplan-assets/pdf-export-DL7VmwpB.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"sha256": "f25cbcb0453279e3ac64eda67df52af100d96c54bb93cb657bc08c355e0484ed",
"rawBytes": 688848,
"gzipBytes": 192327,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"dynamicImports": [
"houseplan-assets/support-de-DZoi-fxk.js",
"houseplan-assets/support-fr-Daosy89Q.js",
"houseplan-assets/support-ru-e8-BrqtH.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"sha256": "4ea3584c8b2bfc86f96c587549ddce49eaf0b918923df2df8e98c9c0a548f835",
"rawBytes": 15886,
"gzipBytes": 4908,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"sha256": "2a0f9af108b8a39889f90d7119d8e7789716c9960ea8e28af38ada07cdc91a8c",
"rawBytes": 10958,
"gzipBytes": 3765,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-BxXtKC5L.js",
"sha256": "4150eb9120fdaafa745761242ff3285056ceaccd7b016a16364574b90e3e2cfe",
"rawBytes": 52970,
"gzipBytes": 17885,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"sha256": "66e70baa00f4c9470ac9a37a229652cf5c5585caa2364de4d467a1c2ebe24358",
"rawBytes": 8370,
"gzipBytes": 3280,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-zIuNvgGV.js",
"sha256": "244bce2d5211106846215be6a9d3dacda4c910d06020144f9ffd5e432c5e8c00",
"rawBytes": 675,
"gzipBytes": 410,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DL7VmwpB.js",
"sha256": "548dced1a8a54f860dbe57e61019d50f058d3ca13abb738d750a3c107c88de8b",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-DfJ7EKF9.js",
"sha256": "0580b9f6191edb664c21dee21e5263489eaf22876bcad67ef12e440abc03e28a",
"rawBytes": 7378,
"gzipBytes": 2879,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-Bl79E-6s.js",
"sha256": "f1bde9db7fe884aabd25130f783a84d8fe27bc9553923c17bcb9b82ee3976475",
"rawBytes": 7620,
"gzipBytes": 2842,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-CkNFmChi.js",
"sha256": "94f96c4a15a3a82ca8be3066629734379dd14eb2ff9e8f6acf369da2378676cb",
"rawBytes": 10304,
"gzipBytes": 3325,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-C_vpFucN.js",
"sha256": "d45dbdfa17a1da9f45bfbca6d6c3cc98849fd5ebcc06c8f72fd6310b04b93aad",
"rawBytes": 14862,
"gzipBytes": 3425,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-D47J-OjO.js",
"sha256": "909d2195fcfca30c2f1279e07e2d7506d470c3daec6fe864c97f7ecc4101e33d",
"rawBytes": 2701,
"gzipBytes": 1344,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
"sha256": "e44860a1af9ec7ffc7ac289c8c76ed1d0dcf0177bd91bbfe1981fdd0d4a62c46",
"rawBytes": 72959,
"gzipBytes": 20242,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-C_vpFucN.js",
"houseplan-assets/summary-panel-metrics-D47J-OjO.js"
]
},
{
"path": "houseplan-assets/support-de-DZoi-fxk.js",
"sha256": "f01d2d00a169519f5f402e99e9c85c9f21a23aeee2a9605d4444910f3250d6de",
"rawBytes": 6167,
"gzipBytes": 2396,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-Daosy89Q.js",
"sha256": "e503a1b4e1b1ed31d8fa09bfe0cdc069a82c1d1627d40407472b336fbc98ccf5",
"rawBytes": 6333,
"gzipBytes": 2432,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-e8-BrqtH.js",
"sha256": "76742313c4b510163c380f8fd9c7fcaf1cc7f8bde6a41bcb0da48621a7c3a3b3",
"rawBytes": 8376,
"gzipBytes": 2876,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-Cs36ouVE.js",
"sha256": "7c808f1139bb1c232627bfe884a579177ec653d16147d6b96066851893b312a4",
"rawBytes": 2698,
"gzipBytes": 1337,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-B8yoJGvc.js",
"sha256": "6ee1530fd0bd7a8c476c79b83404c7d8a5b98cd0b86ee8a73048be75a70cc2c4",
"rawBytes": 2718,
"gzipBytes": 1354,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-BRKShmrQ.js",
"sha256": "7fc89542fa860318d459ce1467a5a8931f3214a9d17f57c177ba01f2cfbbd3e8",
"rawBytes": 3669,
"gzipBytes": 1616,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-DOAnbvlu.js",
"sha256": "313f71677ba0739d9c978185c2136e73e8037b770eb3f9ef8561f692d654406f",
"rawBytes": 10958,
"gzipBytes": 4299,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-Cs36ouVE.js",
"houseplan-assets/topology-fr-B8yoJGvc.js",
"houseplan-assets/topology-ru-BRKShmrQ.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CKlry6R8.js",
"sha256": "39e4a250de0e67e80d8afc02c39c1a79e42d4b3e113107bb3817f4fa90cab1e2",
"rawBytes": 3983,
"gzipBytes": 1789,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d4b480db1327152db7b1459c45692ea6b72fb7ac3bccb525bc06adfe1aab3b8d",
"rawBytes": 1205,
"gzipBytes": 803,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "5aaa677a9c85f4df0f176be31a91ebb5022f5ce129774919269bb0409c310233",
"rawBytes": 6165,
"gzipBytes": 2325,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{aD as e,l as o,aG as t,ez as s,A as a,eA as i,eB as l,eC as n,E as r,c}from"./houseplan-card-BYwTiVnu.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eE as e,eF as n,eB as a}from"./houseplan-card-BYwTiVnu.js";function c(a,c){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",console.warn,n)}function o(e){return function(e,n){return{state:a=>{const c=e.state(a);return"pending"!==c&&n.some(e=>"pending"===e.state(a))?"pending":c},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{c as n,o as s};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{c as a,A as e}from"./houseplan-card-BYwTiVnu.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-B0Dq0vzN.js";import"./form-kit.styles-v5DlO12L.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
.checked=${d.localShow}
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide flex-content
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
<section class="summary-general" aria-labelledby="summary-general-title">
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
<div class="summary-general-grid">
${d.localOnly?e:a`<div class="summary-field">
<label for="summary-panel-title">${b("summary.panel_title")}</label>
<input id="summary-panel-title" type="text" .value=${d.draft.title}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&"title"===y?.path)}
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
</div>`}
${g}
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
</div>
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
</section>
${d.localOnly?e:a`
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
<header class="summary-blocks-heading">
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
<p>${b("summary.blocks_hint")}</p></div>
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
</header>
<div class="summary-editor-blocks">
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
@dragover=${a=>a.preventDefault()}
@drop=${a=>m.drop(a,`block:${t}`)}>
<div class="summary-block-head">
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@dragstart=${a=>m.dragStart(a,`block:${t}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===t||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${t===d.draft.blocks.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
</div>
<input class="summary-block-title" type="text" .value=${s.title}
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
<button class="summary-icon-button summary-visibility" type="button"
?disabled=${d.busy} aria-pressed=${String(s.visible)}
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
<div class="summary-scope">
<select .value=${s.scope.type} ?disabled=${d.busy}
aria-label=${b("summary.scope")}
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
</select>
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
aria-invalid=${"error"===n?.kind?"true":"false"}
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
${c._model.map(e=>a`<option value=${e.id}
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
</select>`:e}
</div>
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
</div>
<div class="summary-block-body">
<div class="summary-editor-values">
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@dragstart=${a=>m.dragStart(a,`value:${t}:${c}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===c||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${c===s.values.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
</div>
<div class="summary-value-label">
<input type="text" .value=${i.label} ?disabled=${d.busy}
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
</div>
<div class="summary-source-field">
<button class="summary-source" type="button"
?disabled=${d.busy}
data-summary-source-owner=${S}
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
aria-invalid=${"error"===$?.kind?"true":"false"}
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
<span class="summary-source-caption"><strong>${g}</strong>
${_?a`<small>${_}</small>`:e}</span>
${l("chevron")}
</button>
${k&&f?a`<div class="summary-source-picker"
@click=${a=>a.stopPropagation()}
@keydown=${a=>{"Escape"===a.key&&(a.preventDefault(),a.stopPropagation(),m.closeSource(!0))}}>
<label>${b("summary.search_entities")}</label>
<input type="search" data-summary-picker-search
?disabled=${d.busy}
aria-label=${b("summary.search_entities")} .value=${d.entityFilter}
@input=${a=>m.setDialog({...d,entityFilter:a.target.value})} />
<div class="summary-source-results" role="listbox"
aria-label=${b("summary.select_source")}>
<div class="summary-source-group">${b("summary.system_group")}</div>
${["device_count","total_area","datetime"].map(e=>a`
<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`system:${e}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
${b(`summary.system.${e}`)}
</button>`)}
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
<button type="button" role="option" class="broken" aria-selected="true"
?disabled=${d.busy}
@click=${()=>m.setSource(s.id,i.id,p)}>
${g}
</button>`:e}
<div class="summary-source-group">${b("summary.entities_group")}</div>
${f.entries.map(e=>a`<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`entity:${e.id}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
<span>${e.label}</span><small>${e.id}</small>
</button>`)}
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
</div>
</div>`:e}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
</div>
<button class="summary-icon-button summary-value-remove" type="button"
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
</div>`})}
<button class="summary-add summary-add-value" type="button"
?disabled=${s.values.length>=20||d.busy}
title=${s.values.length>=20?b("summary.limit_values"):""}
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
${l("plus")}${b("summary.add_value")}
</button>
</div>
<footer class="summary-block-footer">
<button class="summary-delete-block" type="button" ?disabled=${d.busy}
@click=${()=>m.deleteBlock(t)}>${b("summary.delete_block")}</button>
</footer>
</div>
</article>`})}
</div>
<button class="summary-add summary-add-block" type="button"
?disabled=${d.draft.blocks.length>=10||d.busy}
title=${d.draft.blocks.length>=10?b("summary.limit_blocks"):""}
@click=${()=>m.mutate(a=>{a.blocks.push(u(b))})}>
${l("plus")}${b("summary.add_block")}
</button>
</section>
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
?disabled=${d.busy} @click=${()=>m.reload()}>
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
</button>`:e}
`}
</div>
<div class="row summary-editor-footer" slot="footer">
<button class="btn ghost" data-hp="dialog-cancel"
?disabled=${d.busy} @click=${k}>${b("btn.cancel")}</button>
<button class="btn on" data-hp="dialog-confirm"
?disabled=${d.localOnly?!$||d.busy:!p}
@click=${()=>m.save()}>${b("btn.save")}</button>
</div>
</hp-dialog>`};export{m as renderSummaryPanelEditor};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eH as e,N as t,b2 as n,L as i,ae as o,ac as r,c9 as s,eI as a,c8 as c,b1 as l,b0 as d,b3 as u,eJ as f,eK as m,cb as g,eL as v}from"./houseplan-card-BYwTiVnu.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,s as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const t="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,t as fingerprint};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{n as t,c as n}from"./zigbee-topology-DOAnbvlu.js";import{aM as e}from"./houseplan-card-BYwTiVnu.js";import"./namespace-language-zIuNvgGV.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
+1 -1041
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-102
View File
@@ -1,102 +0,0 @@
"""HTTP endpoint for uploading House Plan manual files.
Files (PDF and the like) are uploaded not over WebSocket (its message size limit
breaks the connection on a large PDF) but via a plain multipart POST — like media in HA itself.
"""
from __future__ import annotations
import logging
from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try: # KEY_HASS — the modern way to access hass from the aiohttp application
from homeassistant.components.http import KEY_HASS
except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
file_ext,
sanitize_filename,
sanitize_marker_id,
)
_LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — save a marker file, return its URL."""
url = "/api/houseplan/upload"
name = "api:houseplan:upload"
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
entry = hass.data.get(DOMAIN, {}).get("entry")
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only:
user = request.get("hass_user")
if user is None or not user.is_admin:
return web.json_response({"error": "unauthorized"}, status=403)
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
too_large = False
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
# read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = []
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
break
chunks.append(chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
return web.json_response({"error": "bad_request"}, status=400)
if too_large:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
if ext not in FILE_EXTENSIONS:
return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
)
safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
def _write() -> int:
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
return int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
return web.json_response(
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
)
-19
View File
@@ -1,19 +0,0 @@
{
"domain": "houseplan",
"name": "House Plan",
"codeowners": [
"@Matysh"
],
"config_flow": true,
"dependencies": [
"http",
"frontend",
"websocket_api"
],
"documentation": "https://github.com/Matysh/houseplan-card",
"integration_type": "service",
"iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"version": "1.11.0"
}
@@ -1,18 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Only administrators may edit the layout" }
}
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Only administrators may edit the layout" }
}
}
}
}
@@ -1,18 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" }
}
},
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Правка раскладки только администраторами" }
}
}
}
}
-119
View File
@@ -1,119 +0,0 @@
"""Pure House Plan validation and sanitizers — no Home Assistant dependencies.
Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
import re
import voluptuous as vol
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
MAX_FILE_BYTES = 25 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# ---------- sanitizers ----------
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
(e.g. '..', '../x'); an empty/dots-only result → 'misc'.
"""
cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64]
return cleaned or "misc"
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]
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
def file_ext(filename: str) -> str:
"""Lowercase file extension ('' if none)."""
raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return raw.rsplit(".", 1)[-1].lower() if "." in raw else ""
def valid_space_id(value: str) -> bool:
return bool(SPACE_ID_RE.match(value))
# ---------- voluptuous schemas ----------
POS_SCHEMA = vol.Schema(
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
return room
raise vol.Invalid("room: poly or x/y/w/h is required")
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
vol.Optional("h"): vol.Coerce(float),
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
},
extra=vol.ALLOW_EXTRA,
),
_require_geometry,
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
vol.Required("title"): str,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA],
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
},
extra=vol.ALLOW_EXTRA,
)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
vol.Required("binding"): str,
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): [SPACE_SCHEMA],
vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
@@ -1,221 +0,0 @@
"""House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations
import asyncio
import base64
import binascii
from pathlib import Path
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN,
PLANS_DIR, PLANS_URL,
)
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
)
@callback
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
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_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
def _store(hass: HomeAssistant):
return hass.data[DOMAIN]["store"]
def _config_store(hass: HomeAssistant):
return hass.data[DOMAIN]["config_store"]
def _write_lock(hass: HomeAssistant) -> asyncio.Lock:
"""A single lock over the load→modify→save cycle of both stores.
Without it, parallel WS calls lose changes (last-writer-wins),
and the expected_rev check is not atomic.
"""
return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock())
def _check_write(hass: HomeAssistant, connection) -> bool:
entry = hass.data[DOMAIN].get("entry")
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
return connection.user.is_admin if admin_only else True
# ---------------- layout ----------------
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the saved layout."""
data = await _store(hass).async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
@websocket_api.websocket_command(
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
)
@websocket_api.async_response
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Replace the layout entirely."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
async with _write_lock(hass):
await _store(hass).async_save({"layout": msg["layout"]})
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/update",
vol.Required("device_id"): str,
vol.Required("pos"): POS_SCHEMA,
}
)
@websocket_api.async_response
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):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
store = _store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/delete",
vol.Required("device_id"): str,
}
)
@websocket_api.async_response
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):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
store = _store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
# ---------------- space configuration ----------------
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
@websocket_api.async_response
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 {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
vol.Required("config"): CONFIG_SCHEMA,
vol.Optional("expected_rev"): int,
}
)
@websocket_api.async_response
async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Replace the configuration with optimistic locking (expected_rev).
Protects against races between several open clients: if the config has changed since
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):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration")
return
store = _config_store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
current_rev = data.get("rev", 0)
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
msg["id"], "conflict",
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
new_rev = current_rev + 1
await store.async_save({"config": msg["config"], "rev": new_rev})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- plan uploads ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/set",
vol.Required("space_id"): str,
vol.Required("ext"): vol.In(sorted(PLAN_EXTENSIONS)),
vol.Required("data"): str, # base64
}
)
@websocket_api.async_response
async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Save a space plan file; return the URL for the card."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may upload plans")
return
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
try:
raw = base64.b64decode(msg["data"], validate=True)
except (binascii.Error, ValueError):
connection.send_error(msg["id"], "invalid_data", "data must be valid base64")
return
if len(raw) > MAX_PLAN_BYTES:
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
return
plans_dir = Path(hass.config.path(PLANS_DIR))
path = plans_dir / f"{space_id}.{msg['ext']}"
def _write() -> int:
plans_dir.mkdir(parents=True, exist_ok=True)
# remove old variants with a different extension
for old_ext in PLAN_EXTENSIONS:
old = plans_dir / f"{space_id}.{old_ext}"
if old_ext != msg["ext"] and old.exists():
old.unlink()
path.write_bytes(raw)
return int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
connection.send_result(
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
)
-1041
View File
File diff suppressed because one or more lines are too long
-134
View File
@@ -1,134 +0,0 @@
# House Plan architecture
Updated: 2026-07-04 (v1.2.2). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
```
houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ websocket_api.py # houseplan/layout/get|set|update
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
├─ assets/ # plan sources: f1_plan.svg, f2_plan.svg (REMPLANNER), *_bg.png (old raster versions)
├─ hacs.json # HACS manifest
└─ docs/ # this documentation
```
## Key decisions
1. **One repository — integration + card.** The integration serves the JS
(`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
of the mobile app (unlike `add_extra_js_url`, which remains a fallback for
YAML mode). The user does not need to add the resource manually.
2. **Icon layout lives on the server.** `helpers.storage.Store(1, "houseplan.layout")` →
`.storage/houseplan.layout`. The card reads/writes via `hass.callWS`
(`houseplan/layout/get|set|update`). Fallback — localStorage (when the integration is absent).
3. **No token.** Everything comes from the frontend `hass` object: `hass.states` (reactive),
`hass.devices/entities/areas` (registries). No direct WS connections.
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
Temperatures/LQI/on-off are live by definition (verified by substituting state).
## Coordinate system
- Base space: **1489×1053** ("pixels" of the old PNG render, 1 unit = 1 px).
All rooms, icon positions and floor viewBoxes live in it. DO NOT change without a layout migration.
- 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]
- f2: scale **0.896**, offset **(351, 21)** → rect [351, 21, 1048.4, 961.4]
- computed via raster correlation (cv2.matchTemplate on binarized darkness maps) of the
SVG render against the reference PNG; accuracy ~1 px. The scripts are reproducible (docs/DEVELOPMENT.md).
- Rooms (`ROOMS`) are snapped to the inner faces of walls (semi-automatic: search for the nearest
"dark line" along the profile + manual fine-tuning against overlay renders).
## Card data model (runtime)
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
- only devices with an area from the room list are shown;
- hidden: entry_type=service, integrations from EXCLUDED_DOMAINS, model=Group, scenes, bridges,
myheat sub-devices, duplicates by "name|area";
- **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);
- lamps (mdi:lightbulb) with ≥2 in a room collapse into a group `mdi:lightbulb-group`
(click → menu: the whole group + individual lamps).
## Live data
- Temperature: an entity with device_class=temperature / °C / `_temperature$` → label on the right.
- LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via
`lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip.
- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
of problem classes), unavail (transparency).
## Sizes
`icon_size` in the config = **% of the visible plan area width** (default 2.5). Implementation:
`.stage { container-type: inline-size }` + sizes in `cqw`. Legacy px values (>8) are ignored.
## Sticky header
`.head { position: sticky; top: var(--header-height, 56px) }`; it is MANDATORY that
`ha-card { overflow: visible }` — `overflow: hidden` breaks sticky.
## Device markers (v1.6.0+)
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
served from `/houseplan_files/files/`.
## Server-side configuration (v1.3.0+)
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
"settings": {"exclude_integrations":[],"group_lights":true} }
```
All coordinates are **normalized (0..1 of the space plan)**; the render space is
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
## Integration WS API
| Command | Parameters | Response |
|---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/config/get` | — | `{config, rev}` |
| `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 MB) | `{ok, url}` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
-328
View File
@@ -1,328 +0,0 @@
# Changelog
## v1.11.0 — 2026-07-05 (full English translation + UI localization)
- **UI localization (en/ru)**: every card string moved to `src/i18n.ts` dictionaries.
The language follows the HA user profile (`hass.locale`) automatically; a new
`language: en|ru` card option forces it. The GUI editor got the option and its own
localized labels. Generated device names (light group, unnamed, virtual device)
are localized via a `loc` callback in `BuildCtx`.
- **English-only codebase**: all comments, docstrings, section banners, JSDoc, test
names, backend WS/HTTP error messages and log lines translated to English.
Russian remains only where it is functional or content: the `ru` i18n dictionary,
`translations/ru.json` (config flow), `iconFor` regex patterns matching Russian
device names (with their test fixtures), and the Russian documentation copy.
- **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)
**Backend**
- **Write race eliminated**: all load→modify→save cycles (`layout/set|update|delete`,
`config/set`) are serialized by a shared `asyncio.Lock` — concurrent WS calls no longer lose
changes, and the `expected_rev` check became atomic.
- New WS command `houseplan/layout/delete` — cleans up a position when a marker is deleted
(previously orphans accumulated in `.storage/houseplan.layout`).
- Removed the dead WS command `houseplan/file/set` — files are uploaded over HTTP only
(`/api/houseplan/upload`), as the card already did.
- 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.
- `request.app[KEY_HASS]` instead of the deprecated `request.app["hass"]` (with a fallback for older HA).
**Frontend**
- **The god-component was broken up**: `houseplan-card.ts` 3023 → ~1990 lines.
Styles → `styles.ts`; types → `types.ts`; building devices from the HA registries
(curation, light groups, markers, LQI/temperature) → `devices.ts` (pure functions).
- **Last-writer-wins in the layout eliminated**: `_saveMarker` writes the position pointwise
(`layout/update`), not the whole layout (`layout/set`) — it no longer wipes positions from other
windows (the regression class of the v1.4.4 incident). Rebinding/deletion clean up the old position.
- **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.3 — 2026-07-05 (audit + refactoring + tests)
- 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).
- `_lqiFor` and `_roomLqi` now use the shared `averageLqi` (duplicate averaging removed).
- 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.2 — 2026-07-05 (grid twice as fine)
- The markup/snapping grid step was halved: `GRID_N` 120 → 240. Device positions and room
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).
Verified on the live layout (65 positions — all already on the new grid). Dragging now
allows more precise positioning.
## 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.
This covers devices whose dedicated signal sensor is disabled but the value is available in an attribute.
- 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.
## 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
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.4 — 2026-07-04 (onboarding: required fields + user guidance)
- Initial setup brought to the target scenario: install → space dialog →
rooms → auto device icons.
- **The background is mandatory** when creating a space: "Save" stays disabled without an uploaded
plan (previously only the name was required — an empty space could be created).
- **The room area is mandatory**, with an explicit escape hatch: the main "Save" requires binding to an HA area;
for decorative rooms without devices (hall, sauna) there is a separate "No area" button
(only a name is needed). Previously saving was possible with "name OR area".
- **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.3 — 2026-07-04 (instant start: config cache, data in the background)
- 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
is saved in `localStorage['houseplan_card_cfg_v1']`. In `setConfig` (synchronously, before
hass arrives) it is restored — the plan and icons are drawn immediately from the cache (verified: setConfig
populates the model in 0.2 ms without the server), while fresh data loads in the background and updates the
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
last known plan is shown instead of the empty onboarding.
## v1.8.2 — 2026-07-04 (vector-crisp zoom via viewBox + full-width stage)
- Zoom moved from CSS `transform: scale()` to manipulating the SVG `viewBox`. Previously the layer
was rasterized and then scaled — everything "blurred" when zooming in. Now the browser
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
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
zoomed in the content fills the width without black bars on the sides. Fully zoomed out the
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 (plan zooming)
- 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.
- Implemented via a CSS transform on `.zoomwrap` (translate+scale); the coordinate math
(markup clicks, icon drag, tooltips) was recomputed with zoom/pan in mind — editing
works when zoomed in too. Pan is constrained so the content always covers the stage. Zoom 100–800%.
- `.stage`: overflow hidden + touch-action none (custom gestures instead of the browser's).
## v1.7.4 — 2026-07-04 (PDF over HTTP instead of WebSocket)
- THE ROOT CAUSE of being unable to upload a PDF: the file was sent as base64 in a single WebSocket
message, and WS has a message-size limit — a real manual (>2–3 MB) exceeded it, the connection dropped
("Connection lost"), which both failed the upload and closed the dialog (WS drop → reconnect
→ re-render). Confirmed by test: 1 MB over WS is fine, 3 MB → Connection lost.
- Manual files are now uploaded through the HTTP endpoint `POST /api/houseplan/upload` (multipart,
HomeAssistantView, requires_auth, admin_only check) — like media in HA itself. Verified:
3 MB (182 ms) and 10 MB (446 ms) upload without a hitch.
- Readable errors everywhere (`_errText`): no more "[object Object]"; clear texts for
too_large / bad_ext / unauthorized.
- The WS command houseplan/file/set is kept for compatibility, but the frontend uses HTTP.
## v1.7.3 — 2026-07-04 (device dialog fixes)
- PDF manuals failed to upload: the manual base64 (`btoa(String.fromCharCode(...subarray))`) crashed
on real files (RangeError when spreading large arrays), and when the dialog was closed during an
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
background-click closing for editor dialogs — only explicit Cancel/Save/Esc.
The info card (read-only) still closes on an outside click.
- Verified on live HA: PDFs attach; a background click and dialog data refresh do not close the dialog.
## v1.7.2 — 2026-07-04 (fix for the mobile "configuration error")
- 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
before the element was registered → "configuration error". (All working HACS cards are Lovelace resources.)
- FIX 1: the integration registers the card as a **Lovelace resource** (module) — the frontend waits for
those before rendering. Idempotent (updates the URL on a version change, no duplicates); a fallback to
extra_module_url for Lovelace YAML mode. `_register_lovelace_resource` in __init__.py.
- FIX 2: `_loadFromServer` retries (up to 8 attempts on hass updates) — if hass arrived before
WS readiness, the card does not get stuck on the onboarding but waits for the config.
- 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 (audit + refactoring + tests)
- Removed the sample house baked into the bundle (the dacha, ~245 KB of base64 plans + ROOMS/FLOOR_*): the bundle
went 293 KB → 83 KB. A fresh install shows the "Add a space" onboarding, not someone else's house.
- Removed the legacy→server migration code (the dacha is already on the server config) and the dead
device_overrides/virtual_devices paths (replaced by markers[]).
- Pure logic extracted into src/logic.ts (lqiColor, snapToGrid, segKey, pointInPolygon,
markerIdForBinding, averageLqi) — covered by unit tests (node:test, 9 tests + iconFor).
- 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
(a directory traversal risk via ".."); leading-dot stripping added, empty → misc/file.
- Fixed real bugs previously masked by the truncated rules.ts: DOMAIN_PRIORITY was
cut short (breaking the more-info entity selection); _defaultPositions crashed on polygon rooms
(now bbox). tsc --noEmit added to the build and CI — strict typing passes.
- Removed the junk duplicates src/data/{editor,rules}.ts; versions synchronized (the manifest was stuck at 1.4.0).
- CI: added typecheck, frontend and backend unit tests.
## 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
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 (Phase 3: device editor)
- The "markers" model (config.markers[]): a hybrid — auto-discovered HA devices appear by themselves, markers
edit/rebind/augment them and describe manual/virtual icons. The marker id
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 manuals, an "Open in HA" button); edit mode → the edit dialog.
- The device dialog: name, binding (a picker of all HA+Z2M devices/groups+helpers with a text
filter, minus already-placed ones and duplicates by name|area, plus "Virtual device"), MDI icon
(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.
- Backend: MARKER_SCHEMA in the config; WS houseplan/file/set (files in /config/houseplan/files/,
≤25 MB, served from /houseplan_files/files/); the files static path registered.
- Virtual icons are now draggable and positioned like normal ones (layout by id).
## v1.6.1 — 2026-07-04
- The binding picker hides device duplicates by "name|area" (Tuya/LocalTuya), same as the dedup on the plan.
## v1.5.1 — 2026-07-04 (space management)
- A pencil icon to the right of each space name in the top toolbar + a "+" button
(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, the aspect ratio is detected from the file),
Delete / Cancel / Save buttons. Creating an empty space and deletion (together with all its
rooms and markup, with a confirmation). Saved via config/set with a revision.
## v1.5.0 — 2026-07-04 (light groups = entities)
- Lamps replaced by group entities: HA light groups (platform=group, area from the entity registry)
and Z2M groups (device model=Group) are rendered with the mdi:lightbulb-group icon; a click → the group's
more-info (controlling the whole group and its members natively in HA).
- 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).
- 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 (CRITICAL fix: configuration race)
- 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).
- Fix: optimistic locking — the config carries a `rev`; `config/set` accepts `expected_rev`
and returns a `conflict` error on mismatch; the client re-reads the config.
- 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).
- Positions during drag are saved pointwise via `layout/update` (dirty set), not a full
`layout/set` — the layout is also no longer wiped between windows.
- A config backup before deployment: .storage/houseplan.config.bak-*.
## v1.4.3 — 2026-07-04
- Device icons snap to the same grid as the markup (the icon center = a node, snapping
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 (markup grid)
- The grid step was halved: GRID_N 60 → 120 (step 8.33 render units ≈ 0.83% of the plan width).
- Grid points are rendered ON TOP of the plan and rooms (the grid-rect was moved from below the plan
into the top markup layer); the points are higher-contrast (r=0.14g, outline).
- Dacha data: the boundaries of all 13 rooms were snapped to grid nodes (edge snapping, shifts ≤ half a step;
only the instance config was edited via houseplan/config/set, the code was untouched).
- Card cache: the module URL ?v= is taken from const.VERSION — when updating the frontend, bump
const.py and restart HA, otherwise browsers keep the old module in memory cache.
## v1.4.1 — 2026-07-04 (markup UX)
- 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
and pinned together with it (a shared sticky container .hdr).
- Closing the outline automatically opens the "New room" modal dialog: the display
name, a dropdown of ONLY the free HA areas (not assigned to any room of the config),
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 (Phase 2: room markup editor)
- The "Markup" mode in the card (the mdi:vector-square-edit button, only with a server config):
a grid of points (60 nodes across the width), lines drawn by pairs of clicks with node snapping, a polyline preview.
- A closed outline (a click on the first point) activates "Save": choosing an HA area from a
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
(a click inside + confirm). The "wall" lines are stored in space.segments and reused by
neighboring rooms (shared walls — via clicks on existing nodes).
- 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.
- Backend: ROOM_SCHEMA accepts poly (≥3 vertices) or x/y/w/h; SPACE_SCHEMA — segments.
## v1.3.0 — 2026-07-04 (Phase 1: server-side configuration)
- The `houseplan.config` Store: spaces (plan, aspect, view_box, rooms), device_overrides
(hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights).
- WS: `houseplan/config/get|set`, `houseplan/plan/set` (plan file upload as base64 →
`<config>/houseplan/plans/`, served through /houseplan_files/plans/).
- The card: a resolve model — the server config (coordinates NORMALIZED 0..1, render 1000×1000/aspect)
or the legacy bundle (canvas 1489×1053). Layout v2: {device_id: {s, x, y}} normalized.
- The "To server" button in edit mode — a one-click migration of the legacy config (plans, rooms,
view_box, layout). Performed at the dacha: .storage/houseplan.config + plans/f1.svg,f2.svg.
- Read-side support for device overrides and virtual devices (the management UI — phases 3–4).
## v1.2.2 — 2026-07-04
- The card toolbar (floor tabs) pins under the HA header while scrolling
(`position: sticky; top: var(--header-height)`; ha-card overflow: visible).
- Incident: an intermediate build on the unstable mount produced a broken bundle that crashed the
dashboard rendering; the rule "build only in /tmp + md5 control" was locked into DEVELOPMENT.md.
## v1.2.1 — 2026-07-04
- Icons no longer enlarge on hover/drag (transform: scale removed).
## v1.2.0 — 2026-07-03
- Room boundaries snapped to the walls of the vector plan.
- Zigbee LQI: the value under the icon, the room average in the tooltip, a red→green gradient,
the show_signal option.
- `mdi:lock` for any devices with a lock.* entity (TTLock).
- The hallway switch: the device's area in the HA registry was fixed (it was in detskaia_elina).
## v1.1.0 — 2026-07-03
- Vector backgrounds (SVG from REMPLANNER), automatic scale/offset alignment via raster correlation.
- Icon size as a % of the plan width (container queries, default 2.5%).
## v1.0.1 — 2026-07-03
- fix: nested SVG fragments via lit svg`` (the background/rooms were not rendered).
- fix: the version taken from const instead of a blocking manifest.json read in the event loop.
## v1.0.0 — 2026-07-03
- First release: the Lovelace card (TS+Lit, no token, driven by hass) + the houseplan integration
(WS layout storage, JS serving). The prototype's model carried over: curation, lamp groups,
iconFor, temperature, more-info, area navigation, drag layout.
-77
View File
@@ -1,77 +0,0 @@
# Development and deployment
## Environment (cowork sessions)
- The source of truth for the code is the **git repository** (lives in `/tmp/hpc` during a session,
restored from `houseplan-card.git.bundle`: `git clone houseplan-card.git.bundle hpc`).
- The user's folder `houseplan/houseplan-card/` is a mirror of the repo (rsync after every commit)
+ an up-to-date `houseplan-card.git.bundle`.
### ⚠️ File-sync pitfalls (critical)
1. The network mount sometimes serves files **truncated/scrambled** — edits via the Edit tool
from the Windows side are unreliable. Rule: **apply python patches against a clean copy in /tmp,
write via bash**, with an assert that count(old)==1.
2. **Run the rollup build ONLY in /tmp/hpc** (`npm ci` is already done). A build on the mount once
produced a syntactically valid but broken bundle ("wi is not defined") that crashed the rendering
of ALL HA dashboards (the card is loaded as an extra_module on every page!).
3. `.git` cannot be created on the mount ("Operation not permitted" on dot-directories) — hence the bundle.
## Tests
- Frontend: `npm test` — compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
(test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
- Backend: `python -m pytest tests_backend/` — pure validation of custom_components/houseplan/validation.py
(loaded by path, without importing the HA package).
- IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`,
which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
## Build
```bash
cd /tmp/hpc && npm ci # once
npx rollup -c # → dist/houseplan-card.js
node --check dist/houseplan-card.js
cp dist/houseplan-card.js custom_components/houseplan/frontend/
```
## Deployment to the dacha (ha.jbstudio.pro)
- 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/`
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
- **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
(inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
with no-cache).
- After deploying JS — check in the browser (Ctrl+F5) and the console (there must be no errors from
houseplan-card.js; a broken bundle takes down all dashboards).
## Frontend cache and the "empty view"
- The card module URL contains `?v=<VERSION from const.py>`. Browsers keep the ES module in
memory cache: after deploying new JS **bump VERSION in const.py and restart HA**,
otherwise a plain F5 will keep the old version.
- After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
## Release
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
`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`.
## Reproducible scripts (data)
- Extracting the geometry/backgrounds from the prototype and generating `src/data/*` — see the commit
history and docs/ARCHITECTURE.md (SVG→base-space transforms: f1 0.647/(490,27), f2 0.896/(351,21)).
- Room fitting: render the plan with rectangles overlaid (cv2) → snap to walls → manual fine-tuning.
## Production objects in HA (the dacha)
- Dashboard `plan-doma`, panel view, card `custom:houseplan-card` (icon_size 2.5).
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
-73
View File
@@ -1,73 +0,0 @@
# Roadmap: from a "dacha map" to a publishable universal integration
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)
- We write a proper full component following HA dev docs patterns, not a hardcoded feature.
- 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.
- Document everything immediately in docs/ (session context gets lost).
- Storage versioning (Store minor_version + async_migrate) from day one.
## Phase 0 — Publication hygiene (quick, no new features)
- [x] hacs.json, manifest with the required keys, custom_components/* structure
- [x] CI: hacs/action + hassfest (workflow validate.yml) — added, verify on GitHub
- [x] brand/icon.png
- [ ] Public GitHub repository: description, topics, issues on; first Release v1.2.x
- [ ] README EN (primary) + README.ru.md; screenshots/GIF (mandatory for the HACS storefront)
- [ ] Replace codeowners/documentation/issue_tracker with real URLs after the repo is created
## 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)
New store `houseplan.config` (Store v1):
```json
{ "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"}] } ],
"device_overrides": { "<device_id>": {"hidden":bool,"icon":str,"name":str} },
"virtual_devices": [ {"id","space","name","icon","x","y","note"?, "entity_id"?} ],
"settings": {"exclude_integrations": [...], "group_lights": bool, ...} }
```
- WS API v2: `houseplan/config/get|set`, `houseplan/plan/upload` (plan file →
`<config>/houseplan/` via the process executor, served through a static path), layout as today.
- The card: when a server config exists it uses it; the dacha bundle data becomes a
**fallback example** and is then removed (a migration script will push it into the Store).
- Coordinate units: normalized (0..1 of the plan) for new configs — independence from the
source resolution; the dacha migration will convert 1489×1053 → normalized.
## 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)
- A "Setup" mode (separate from drag layout): drawing/resizing room rectangles
on top of the plan, binding to an area (ha-area-picker selector), viewBox (frame) editing.
- Later: polygonal rooms (SVG path), plan rotations.
- Plan upload from the UI (file upload → WS) + picking existing media.
## Phase 3 — Device management ← ✅ DONE in v1.6.x (hiding, icon, name, model, link, description, PDF, rebinding, "+", virtual)
- 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 overrides: hide, custom icon (ha-icon-picker), custom name. Stored in config.
- Configurable curation: integration/domain exclusions in the options flow instead of hardcode.
## Phase 4 — Virtual devices ← ✅ basics in v1.6.x (CRUD of virtual markers in the shared dialog; further note/icon polish as needed)
- CRUD of virtual markers (name, icon, coordinates, note; optionally a link to an
entity/URL): septic tank, valve, a meter without a sensor, etc. Rendered like normal icons,
click → a card with the note or more-info of the bound entity.
## Phase 5 — UX/feature polish
- Configurable click actions: toggle for lights/outlets, long-press → more-info.
- Live-indication tuning (theme-aware colors, badges), light theme.
- Tooltips on touch devices (long-press), accessibility (keyboard, aria).
- LQI option: "bad signal" threshold, hiding the labels on non-zigbee instances.
## Phase 6 — Quality and publication
- Tests: pytest (config_flow, websocket_api, Store migrations) + hassfest/hacs action in CI;
frontend: vitest for rules/geometry utilities.
- Typing: strict TS interfaces for hass (custom-card-helpers or our own), mypy for python.
- Translations for integration+card: en + ru (translations/, card string localization).
- Quality scale bronze → silver checklist; PR into hacs/default; optionally a PR into
home-assistant/brands (the brand/ in the repo suffices for now).
## Open questions
- Publication name: "House Plan Card"? the `houseplan` domain is not taken in HACS default — verify.
- MIT license (package.json is already MIT) — add a LICENSE file.
- Plan formats: SVG preferred (vector, size), PNG support is mandatory.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

-5
View File
@@ -1,5 +0,0 @@
{
"name": "House Plan (card + storage)",
"render_readme": true,
"homeassistant": "2024.6.0"
}
-915
View File
@@ -1,915 +0,0 @@
{
"name": "houseplan-card",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"serialize-javascript": "^6.0.1",
"smob": "^1.0.0",
"terser": "^5.17.4"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-typescript": {
"version": "11.1.6",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz",
"integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.14.0||^3.0.0||^4.0.0",
"tslib": "*",
"typescript": ">=3.7.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
},
"tslib": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
"integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.62.2",
"@rollup/rollup-android-arm64": "4.62.2",
"@rollup/rollup-darwin-arm64": "4.62.2",
"@rollup/rollup-darwin-x64": "4.62.2",
"@rollup/rollup-freebsd-arm64": "4.62.2",
"@rollup/rollup-freebsd-x64": "4.62.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
"@rollup/rollup-linux-arm-musleabihf": "4.62.2",
"@rollup/rollup-linux-arm64-gnu": "4.62.2",
"@rollup/rollup-linux-arm64-musl": "4.62.2",
"@rollup/rollup-linux-loong64-gnu": "4.62.2",
"@rollup/rollup-linux-loong64-musl": "4.62.2",
"@rollup/rollup-linux-ppc64-gnu": "4.62.2",
"@rollup/rollup-linux-ppc64-musl": "4.62.2",
"@rollup/rollup-linux-riscv64-gnu": "4.62.2",
"@rollup/rollup-linux-riscv64-musl": "4.62.2",
"@rollup/rollup-linux-s390x-gnu": "4.62.2",
"@rollup/rollup-linux-x64-gnu": "4.62.2",
"@rollup/rollup-linux-x64-musl": "4.62.2",
"@rollup/rollup-openbsd-x64": "4.62.2",
"@rollup/rollup-openharmony-arm64": "4.62.2",
"@rollup/rollup-win32-arm64-msvc": "4.62.2",
"@rollup/rollup-win32-ia32-msvc": "4.62.2",
"@rollup/rollup-win32-x64-gnu": "4.62.2",
"@rollup/rollup-win32-x64-msvc": "4.62.2",
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/smob": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz",
"integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/terser": {
"version": "5.48.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "houseplan-card",
"version": "1.11.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
"scripts": {
"build": "tsc --noEmit && rollup -c",
"watch": "rollup -c --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@rollup/plugin-terser": "^0.4.4",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
},
"dependencies": {
"lit": "^3.1.3"
}
}
-13
View File
@@ -1,13 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/houseplan-card.ts',
output: {
file: 'dist/houseplan-card.js',
format: 'es',
sourcemap: false,
},
plugins: [resolve(), typescript(), terser({ format: { comments: false } })],
};
-277
View File
@@ -1,277 +0,0 @@
/**
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, DOMAIN_PRIORITY } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
/** Build context: a slice of hass + config resolution. */
export interface BuildCtx {
hass: any;
/** area_id → space_id (only zones bound to rooms). */
areaToSpace: Record<string, string>;
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set<string>;
showAll: boolean;
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[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(hass.entities)) {
if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
}
return map;
}
export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
for (const eid of entIds) {
const p = hass.entities[eid]?.platform;
if (p) return p;
}
return '';
}
export function isTempEntity(hass: any, eid: string): boolean {
const st = hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
return (
a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid)
);
}
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
const ents = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden);
const usable = ents.filter((e) => !e.reg.entity_category);
const pool = usable.length ? usable : ents;
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => isTempEntity(hass, e.eid));
if (t) return t.eid;
}
for (const dom of DOMAIN_PRIORITY) {
const found = pool.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
return pool[0]?.eid;
}
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
export function lqiFor(hass: any, entIds: string[]): number | null {
const vals: number[] = [];
for (const eid of entIds) {
const st = hass.states[eid];
if (!st) continue;
const unit = (st.attributes?.unit_of_measurement || '').toLowerCase();
// 1) dedicated signal sensor: Z2M *_linkquality, ZHA *_lqi, or “lqi” units
if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') {
const v = parseFloat(st.state);
if (!isNaN(v)) vals.push(v);
continue;
}
// 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;
if (av != null) {
const v = parseFloat(av);
if (!isNaN(v)) vals.push(v);
}
}
return averageLqi(vals);
}
export function tempFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!isTempEntity(hass, eid)) continue;
const st = hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** 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 }[] {
if (!enabled) return [];
const res: { eid: string; name: string; area: string }[] = [];
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
if (!eid.startsWith('light.') || reg.hidden) continue;
let area: string | null = null;
if (reg.platform === 'group') {
area = reg.area_id || null;
} else if (reg.device_id) {
const dev = hass.devices[reg.device_id];
if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null;
else continue;
} else {
continue;
}
if (!area) continue;
const st = hass.states[eid];
res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area });
}
return res;
}
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model;
item.link = m.link ?? null;
item.description = m.description ?? null;
item.pdfs = m.pdfs || [];
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const claimed = new Set<string>();
for (const m of markers) {
const [kind, ref] = m.binding.split(':');
if ((kind === 'device' || kind === 'entity') && ref) claimed.add(m.binding);
}
const markerFor = (kind: string, ref: string) => markers.find((m) => m.binding === kind + ':' + ref);
const seen: Record<string, number> = {};
const rest: DevItem[] = [];
// 1) HA auto-discovered devices (not claimed by a marker, not hidden)
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
// curation (can be turned off with the “show all” toggle)
if (!showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
if (dom === 'myheat' && dev.via_device_id) continue;
}
const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
const key = name + '|' + area;
let icon = iconFor(name, dev.model);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
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;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
const item: DevItem = {
id: dev.id,
name: dispName,
model: dev.model || '',
area,
space: areaToSpace[area],
icon,
entities: entIds,
bindingKind: 'device',
bindingRef: dev.id,
pdfs: [],
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
rest.push(item);
}
// 2) light groups (not claimed by a marker)
for (const g of groups) {
if (!areaToSpace[g.area]) continue;
if (claimed.has('entity:' + g.eid)) continue;
rest.push({
id: 'lg_' + g.eid,
name: g.name,
model: loc('device.light_group'),
area: g.area,
space: areaToSpace[g.area],
icon: 'mdi:lightbulb-group',
entities: [g.eid],
primary: g.eid,
bindingKind: 'entity',
bindingRef: g.eid,
pdfs: [],
});
}
// 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) {
if (m.hidden) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
const area = m.area || dev?.area_id || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const entIds = dev ? entsBy[dev.id] || [] : [];
let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle';
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
name: dev?.name_by_user || dev?.name || loc('device.fallback'),
model: dev?.model || '',
area,
space,
icon,
entities: entIds,
bindingKind: 'device',
bindingRef: ref,
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
const reg = h.entities[ref];
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const st = h.states[ref];
const item: DevItem = {
id: m.id,
name: reg?.name || st?.attributes?.friendly_name || ref,
model: '',
area,
space,
icon: 'mdi:shape-outline',
entities: [ref],
primary: ref,
bindingKind: 'entity',
bindingRef: ref,
};
applyMarker(item, m);
rest.push(item);
} else {
// virtual
const area = m.area || '';
const space = m.space || (area && areaToSpace[area]) || firstSpaceId;
const item: DevItem = {
id: m.id,
name: m.name || loc('device.virtual'),
model: m.model || '',
area,
space,
icon: m.icon || 'mdi:map-marker',
entities: [],
bindingKind: 'virtual',
virtual: true,
};
applyMarker(item, m);
rest.push(item);
}
}
return rest;
}
-105
View File
@@ -1,105 +0,0 @@
/** Card configuration editor (Lovelace GUI). */
import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
class HouseplanCardEditor extends LitElement {
public hass?: any;
private _config?: any;
private _spaces: { value: string; label: string }[] | null = null;
private _spacesLoading = false;
static properties = {
hass: { attribute: false },
_config: { state: true },
_spaces: { state: true },
};
public setConfig(config: any): void {
this._config = config;
}
/** Spaces come from the integration's server config — never hard-coded. */
private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true;
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({
value: s.id,
label: s.title || s.id,
}));
} catch {
this._spaces = [];
} finally {
this._spacesLoading = false;
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
private get _schema(): any[] {
const spaces = this._spaces || [];
const L = this._lang;
return [
{ name: 'title', selector: { text: {} } },
spaces.length
? {
name: 'default_floor',
selector: { select: { mode: 'dropdown', options: spaces } },
}
: { 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: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
];
}
protected render() {
if (!this.hass || !this._config) return nothing;
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
.hass=${this.hass}
.data=${this._config}
.schema=${this._schema}
.computeLabel=${(s: any) => labels[s.name] || s.name}
@value-changed=${this._valueChanged}
></ha-form>`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...this._config, ...ev.detail.value };
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
e.detail = { config };
this.dispatchEvent(e);
}
}
if (!customElements.get('houseplan-card-editor')) {
customElements.define('houseplan-card-editor', HouseplanCardEditor);
}
File diff suppressed because it is too large Load Diff
-315
View File
@@ -1,315 +0,0 @@
/**
* 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 };
-114
View File
@@ -1,114 +0,0 @@
/**
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
*/
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string {
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
return `hsl(${Math.round(hue)}, 85%, 55%)`;
}
/** Snap a coordinate to the nearest grid node with step pitch. */
export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch;
}
/** Canonical key of a segment (independent of direction). */
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];
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 {
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
}
/** Point inside a polygon (ray casting). */
export function pointInPolygon(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
*/
export function markerIdForBinding(
binding: string,
existingId: string | undefined,
newId: () => string,
): string {
const [kind, ref] = binding.split(':');
if (kind === 'device') return ref;
if (kind === 'entity') return 'lg_' + ref;
return existingId && existingId.startsWith('v_') ? existingId : newId();
}
/** Average LQI over a set of values (or null). */
export function averageLqi(values: number[]): number | null {
if (!values.length) return null;
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
}
/** “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 } {
const planA = vb[2] / vb[3];
if (aspect > planA) {
const h = vb[3], w = vb[3] * aspect;
return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
}
const w = vb[2], h = vb[2] / aspect;
return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
}
/** Push points apart: no closer than minDist to each other, within rectangle b with padding pad. Mutates pts. */
export function declump(
pts: { x: number; y: number }[],
b: { x: number; y: number; w: number; h: number },
minDist: number,
pad: number,
): void {
if (pts.length < 2) return;
const minX = b.x + pad, maxX = b.x + b.w - pad, minY = b.y + pad, maxY = b.y + b.h - pad;
for (let it = 0; it < 60; it++) {
let moved = false;
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
const dist = Math.hypot(dx, dy) || 0.001;
if (dist < minDist) {
const push = (minDist - dist) / 2;
const ux = dx / dist, uy = dy / dist;
pts[i].x -= ux * push; pts[i].y -= uy * push;
pts[j].x += ux * push; pts[j].y += uy * push;
moved = true;
}
}
}
for (const q of pts) {
q.x = Math.max(minX, Math.min(maxX, q.x));
q.y = Math.max(minY, Math.min(maxY, q.y));
}
if (!moved) break;
}
}
/**
* Safe URL for <a href>: only http(s) and relative paths are allowed.
* Rejects javascript:, data: and other dangerous schemes (XSS via config).
*/
export function safeUrl(url: string | null | undefined): string | null {
if (!url) return null;
const u = url.trim();
if (/^(https?:)?\/\//i.test(u) || u.startsWith('/') || /^[\w./#?=&%~-]+$/i.test(u)) {
if (/^[a-z][\w+.-]*:/i.test(u) && !/^https?:/i.test(u)) return null;
return u;
}
return null;
}
-56
View File
@@ -1,56 +0,0 @@
/**
* Prototype rules — ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.2–4.5).
*/
/** Integration domains whose devices are hidden (curation). */
export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
'upnp_serial_number',
]);
const ICON_RULES: Array<[RegExp, string]> = [
[/протечк/, 'mdi:water-alert'],
[/клапан/, 'mdi:pipe-valve'],
[/дым/, 'mdi:smoke-detector'],
[/термоголов/, 'mdi:radiator'],
[/температ/, 'mdi:thermometer'],
[/qingping|air monitor|молекул/, 'mdi:air-filter'],
[/штор/, 'mdi:roller-shade'],
[/розетк|plug/, 'mdi:power-socket-de'],
[/выключат|switch/, 'mdi:light-switch'],
[/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
[/камер|camera/, 'mdi:cctv'],
[/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
[/ворота|garage/, 'mdi:garage-variant'],
[/калитк|door|открыт/, 'mdi:door'],
[/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
[/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
[/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
[/холодильник|fridge/, 'mdi:fridge'],
[/стиральн|washer/, 'mdi:washing-machine'],
[/сушилк|dryer/, 'mdi:tumble-dryer'],
[/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
[/soundbar|колонк|станц/, 'mdi:soundbar'],
[/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
[/keenetic|роутер|router/, 'mdi:router-wireless'],
[/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
[/slzb|координат|zigbee/, 'mdi:zigbee'],
];
/** Pick an MDI icon by device name/model. */
export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) {
if (pat.test(s)) return icon;
}
return 'mdi:chip';
}
/** Domain priority for picking the device's “primary” entity (more-info). */
export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
'alarm_control_panel', 'sensor', 'binary_sensor', 'event', 'button',
'number', 'select', 'update',
];
-727
View File
@@ -1,727 +0,0 @@
/** Styles of the House Plan card (extracted from the component). */
import { css } from 'lit';
export const cardStyles = css`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
--hp-txt: var(--primary-text-color, #e6edf3);
--hp-muted: var(--secondary-text-color, #8aa0b3);
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
}
ha-card {
overflow: visible; /* overflow:hidden breaks position:sticky on the header */
}
.empty {
padding: 40px 24px;
color: var(--hp-txt);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty .big {
--mdc-icon-size: 56px;
color: var(--hp-accent);
opacity: 0.7;
}
.empty .muted {
color: var(--hp-muted);
font-size: 13px;
margin: 0;
}
.empty .btn {
margin-top: 8px;
}
.hdr {
position: sticky;
top: var(--header-height, 56px);
z-index: 20;
background: var(--card-background-color, var(--hp-bg));
border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0;
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--hp-line);
flex-wrap: wrap;
}
.title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.title ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 18px;
}
.tabs {
display: flex;
gap: 4px;
background: rgba(127, 127, 127, 0.12);
padding: 3px;
border-radius: 10px;
flex-wrap: wrap;
}
@media (max-width: 620px) {
.head { gap: 6px; padding: 8px 10px; }
.head .count { display: none; }
.head .title { font-size: 14px; }
}
.tab {
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 6px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
}
.tab:hover {
color: var(--hp-txt);
}
.tab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
.count {
font-size: 12px;
color: var(--hp-muted);
}
.spacer {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--hp-line);
background: transparent;
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
font-size: 12.5px;
}
.btn ha-icon {
--mdc-icon-size: 17px;
}
.btn:hover {
border-color: var(--hp-accent);
}
.btn.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
border-color: var(--hp-accent);
}
.btn.ghost {
border: none;
}
.btn[disabled] {
opacity: 0.5;
pointer-events: none;
}
.stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
touch-action: none; /* custom pinch/pan gestures */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.zoomwrap {
position: absolute;
inset: 0;
}
.zoomctl {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 8px;
padding: 2px;
}
.zoomctl .zb {
border: none;
padding: 5px 7px;
}
.zoomctl .zb[disabled] {
opacity: 0.4;
pointer-events: none;
}
.zoombadge {
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
.stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.room {
transition: 0.12s;
cursor: pointer;
}
.room.overlay {
fill: transparent;
stroke: transparent;
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
}
.room.yard:hover {
fill: rgba(75, 140, 90, 0.24);
stroke: #6fbf86;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
font-weight: 600;
pointer-events: none;
text-anchor: middle;
}
.stage.edit .room {
pointer-events: none;
}
.stage.markup {
cursor: crosshair;
}
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer {
display: none; /* in markup mode icons must not get in the way */
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.4;
}
.seg {
stroke: var(--hp-accent);
stroke-width: 2.5;
stroke-linecap: round;
}
.pathline {
stroke: #ffc14d;
stroke-width: 3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview {
stroke: #ffc14d;
stroke-width: 2;
stroke-dasharray: 6 5;
opacity: 0.7;
}
.vertex {
fill: #ffc14d;
stroke: #4a2800;
stroke-width: 1;
}
.vertex.first {
fill: #4bd28f;
stroke: #04121f;
}
.areasel,
.namein {
background: var(--hp-bg);
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
}
.namein {
width: 130px;
}
.devlayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.dev {
position: absolute;
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: grab;
pointer-events: auto;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
z-index: 2;
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
}
.dev:active {
cursor: grabbing;
}
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev.on {
background: var(--hp-on);
border-color: var(--hp-on);
color: #503c00;
box-shadow: 0 0 8px rgba(255, 212, 92, 0.7);
}
.dev.open {
background: var(--hp-open);
border-color: var(--hp-open);
color: #4a2800;
}
.dev.unavail {
opacity: 0.35;
}
.dev.virtual {
border-style: dashed;
}
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
}
.dev .tval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: #dff1ff;
white-space: nowrap;
pointer-events: none;
}
.dev .lqi {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
pointer-events: none;
}
.editbar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--hp-line);
font-size: 13px;
flex-wrap: wrap;
}
.tab .tabedit {
--mdc-icon-size: 13px;
margin-left: 6px;
opacity: 0.4;
vertical-align: middle;
}
.tab:hover .tabedit {
opacity: 0.9;
}
.tab.tabadd {
padding: 6px 8px;
}
.tab.tabadd ha-icon {
--mdc-icon-size: 15px;
}
.planrow {
display: flex;
align-items: center;
gap: 10px;
}
.planprev {
max-width: 120px;
max-height: 70px;
border: 1px solid var(--hp-line);
border-radius: 6px;
background: #fff;
}
.planname {
font-size: 12.5px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planname.muted {
color: var(--hp-muted);
}
.filebtn {
cursor: pointer;
}
.btn.danger {
border-color: #b3402a;
color: #ff7a5c;
}
.dialog .row .spacer {
flex: 1;
}
.dialog.wide {
width: min(440px, 94vw);
}
.dialog .body {
max-height: 66vh;
overflow-y: auto;
}
.descin {
width: 100%;
box-sizing: border-box;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.bindsel {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--hp-line);
border-radius: 8px;
padding: 8px;
}
.bindsel .opt {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--hp-line);
background: transparent;
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
font-size: 12.5px;
font-family: inherit;
}
.bindsel .opt.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
border-color: var(--hp-accent);
}
.curbind {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--hp-txt);
flex-wrap: wrap;
}
.curbind .ref {
color: var(--hp-muted);
font-size: 11px;
}
.candlist {
max-height: 160px;
overflow-y: auto;
border-top: 1px solid var(--hp-line);
}
.cand {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 6px;
font-size: 12.5px;
}
.cand:hover {
background: rgba(127, 127, 127, 0.15);
}
.cand.sel {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
.cand .cs {
color: var(--hp-muted);
font-size: 11px;
white-space: nowrap;
}
.cand.sel .cs {
color: var(--text-primary-color, #fff);
opacity: 0.85;
}
.cand.muted {
color: var(--hp-muted);
cursor: default;
}
.pdfedit {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.pdftag {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--hp-line);
border-radius: 6px;
padding: 3px 6px;
font-size: 12px;
}
.pdftag a {
color: var(--hp-txt);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdftag .x {
--mdc-icon-size: 15px;
cursor: pointer;
color: var(--hp-muted);
}
.pdftag .x:hover {
color: #ff7a5c;
}
.inforow {
display: flex;
gap: 10px;
font-size: 13px;
margin: 3px 0;
}
.inforow .k {
color: var(--hp-muted);
min-width: 84px;
}
.inforow a {
color: var(--hp-accent);
word-break: break-all;
}
.infodesc {
font-size: 13px;
white-space: pre-wrap;
margin-top: 6px;
}
.infodesc.muted {
color: var(--hp-muted);
}
.pdflist {
display: flex;
flex-direction: column;
gap: 4px;
}
.pdf {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--hp-accent);
text-decoration: none;
}
ha-icon-picker {
display: block;
}
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
width: min(360px, 92vw);
overflow: hidden;
}
.dialog .hd {
padding: 12px 16px;
font-weight: 600;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 8px;
}
.dialog .hd ha-icon {
color: var(--hp-accent);
}
.dialog .body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.dialog .body label {
font-size: 12px;
color: var(--hp-muted);
margin-top: 6px;
}
.dialog .body .namein,
.dialog .body .areasel {
width: 100%;
box-sizing: border-box;
}
.dialog .row {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--hp-line);
}
.editbar .warn {
color: #ffc14d;
}
.editbar .sname {
font-weight: 600;
}
.editbar input {
width: 74px;
background: transparent;
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 5px 7px;
font-size: 13px;
}
.editbar label,
.editbar .hint {
color: var(--hp-muted);
font-size: 12px;
}
.menuwrap {
position: fixed;
inset: 0;
z-index: 80;
}
.menu {
position: fixed;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
border-radius: 10px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
min-width: 210px;
max-width: 300px;
overflow: hidden;
transform: translate(0, 8px);
}
.menu .hd {
padding: 8px 12px;
font-weight: 600;
font-size: 12.5px;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 6px;
}
.menu .hd ha-icon,
.menu .it.all ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 16px;
}
.menu .it {
padding: 8px 12px;
font-size: 12.5px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.menu .it ha-icon {
--mdc-icon-size: 16px;
color: var(--hp-muted);
}
.menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.menu .it.all {
color: var(--hp-accent);
font-weight: 600;
}
.tip {
position: fixed;
pointer-events: none;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
font-size: 12.5px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 99;
max-width: 260px;
}
.tip .m {
color: var(--hp-muted);
font-size: 11px;
display: block;
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 9px 16px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 120;
max-width: 90vw;
}
`;
-76
View File
@@ -1,76 +0,0 @@
/** Shared types of the House Plan card. */
export interface RoomCfg {
id?: string;
name: string;
area: string | null;
x?: number;
y?: number;
w?: number;
h?: number;
poly?: number[][]; // polygon in render units (model) / normalized (config)
}
export interface SpaceModel {
id: string;
title: string;
vb: number[]; // render units
bg: { href: string; x: number; y: number; w: number; h: number } | null;
rooms: RoomCfg[]; // render units
}
export interface PdfRef {
name: string;
url: string;
}
/** Config marker: edits/augments an auto-discovered device OR describes a manual/virtual icon. */
export interface Marker {
id: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
space?: string | null;
area?: string | null;
hidden?: boolean;
name?: string | null;
icon?: string | null;
model?: string | null;
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
}
export interface ServerConfig {
spaces: any[];
markers: Marker[];
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
}
export interface DevItem {
id: string;
name: string;
model: string;
area: string;
space: string;
icon: string;
entities: string[];
primary?: string;
temp?: number | null;
virtual?: boolean;
marker?: Marker; // linked config marker (metadata, overrides)
bindingKind?: 'device' | 'entity' | 'virtual';
bindingRef?: string; // device_id / entity_id
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
}
export interface CardConfig {
type: string;
title?: string;
default_floor?: string;
icon_size?: number;
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
}
-130
View File
@@ -1,130 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl,
} from '../test-build/logic.js';
import { iconFor } from '../test-build/rules.js';
test('lqiColor: boundaries and midpoint', () => {
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)');
assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)');
assert.equal(lqiColor(0), 'hsl(0, 85%, 55%)');
assert.equal(lqiColor(255), 'hsl(120, 85%, 55%)');
});
test('snapToGrid', () => {
assert.equal(snapToGrid(0, 10), 0);
assert.equal(snapToGrid(14, 10), 10);
assert.equal(snapToGrid(16, 10), 20);
});
test('segKey: direction does not matter', () => {
assert.equal(segKey([0, 0], [10, 5]), segKey([10, 5], [0, 0]));
assert.notEqual(segKey([0, 0], [10, 5]), segKey([0, 0], [10, 6]));
});
test('samePoint with tolerance', () => {
assert.ok(samePoint([1, 1], [1.0005, 0.9995]));
assert.ok(!samePoint([1, 1], [1.5, 1]));
});
test('pointInPolygon: square', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
assert.ok(pointInPolygon([5, 5], sq));
assert.ok(!pointInPolygon([15, 5], sq));
assert.ok(!pointInPolygon([-1, -1], sq));
});
test('pointInPolygon: L-shaped polygon', () => {
const L = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]];
assert.ok(pointInPolygon([1, 5], L));
assert.ok(pointInPolygon([5, 1], L));
assert.ok(!pointInPolygon([5, 5], L));
});
test('markerIdForBinding', () => {
let n = 0; const nid = () => 'v_new' + n++;
assert.equal(markerIdForBinding('device:abc', undefined, nid), 'abc');
assert.equal(markerIdForBinding('entity:light.x', undefined, nid), 'lg_light.x');
assert.equal(markerIdForBinding('virtual', 'v_existing', nid), 'v_existing');
assert.equal(markerIdForBinding('virtual', undefined, nid), 'v_new0');
assert.equal(markerIdForBinding('virtual', 'abc', nid), 'v_new1');
});
test('averageLqi', () => {
assert.equal(averageLqi([]), null);
assert.equal(averageLqi([100]), 100);
assert.equal(averageLqi([100, 200]), 150);
assert.equal(averageLqi([1, 2, 2]), 2);
});
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('Замок Терраса', 'TTLock'), 'mdi:lock');
assert.equal(iconFor('Настенная лампа 1', 'Yandex Bulb'), 'mdi:lightbulb');
assert.equal(iconFor('Ворота', 'Tuya Garage'), 'mdi:garage-variant');
assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator');
assert.equal(iconFor('Unknown gadget', 'XYZ'), 'mdi:chip');
});
test('fitView: portrait plan in a wide scene — margins on the sides, whole plan inside', () => {
// vb 100x200 (aspect 0.5), scene aspect 2 → view wider than the plan, height = 200
const v = fitView([0, 0, 100, 200], 2);
assert.equal(v.h, 200);
assert.equal(v.w, 400); // 200*2
assert.equal(v.x, -150); // (100-400)/2 centering
assert.equal(v.y, 0);
// 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);
});
test('fitView: scene aspect matches the plan — view == vb', () => {
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);
});
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 pts = [ { x: 50, y: 50 }, { x: 51, y: 50 }, { x: 50, y: 51 } ];
declump(pts, b, 20, 5);
// all pairs no closer than ~minDist (with tolerance for clamping to the bounds)
for (let i = 0; i < pts.length; i++)
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);
assert.ok(d > 12, `pair ${i},${j} too close: ${d}`);
}
// within the bounds [5..95]
for (const q of pts) {
assert.ok(q.x >= 5 && q.x <= 95 && q.y >= 5 && q.y <= 95);
}
});
test('declump: a single point does not move', () => {
const pts = [{ x: 30, y: 40 }];
declump(pts, { x: 0, y: 0, w: 100, h: 100 }, 20, 5);
assert.deepEqual(pts, [{ x: 30, y: 40 }]);
});
test('averageLqi: empty → null, otherwise the rounded average', () => {
assert.equal(averageLqi([]), null);
assert.equal(averageLqi([100, 200]), 150);
assert.equal(averageLqi([1, 2, 2]), 2);
});
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('http://x.ru'), 'http://x.ru');
assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf');
assert.equal(safeUrl('/houseplan_files/files/m1/doc.pdf?v=1'), '/houseplan_files/files/m1/doc.pdf?v=1');
assert.equal(safeUrl('docs/manual.pdf'), 'docs/manual.pdf');
assert.equal(safeUrl('javascript:alert(1)'), null);
assert.equal(safeUrl('data:text/html,<script>'), null);
assert.equal(safeUrl('vbscript:x'), null);
assert.equal(safeUrl(''), null);
assert.equal(safeUrl(null), null);
assert.equal(safeUrl(undefined), null);
assert.equal(safeUrl(' https://x.ru '), 'https://x.ru');
});
-99
View File
@@ -1,99 +0,0 @@
"""Unit tests for the pure House Plan validation (validation.py is loaded by path,
without importing the HA integration package)."""
import importlib.util
import os
import pytest
import voluptuous as vol
_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"custom_components", "houseplan", "validation.py",
)
_spec = importlib.util.spec_from_file_location("hp_validation", _PATH)
v = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v)
def test_sanitize_marker_id():
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
assert v.sanitize_marker_id(".") == "misc"
assert v.sanitize_marker_id("") == "misc"
assert len(v.sanitize_marker_id("a" * 200)) == 64
def test_sanitize_filename_strips_path():
assert v.sanitize_filename("/a/b/c/manual.pdf") == "manual.pdf"
assert v.sanitize_filename("..\\..\\evil.pdf") == "evil.pdf" # backslashes = a path
assert v.sanitize_filename("...hidden.pdf") == "hidden.pdf" # leading dots stripped
def test_file_ext():
assert v.file_ext("manual.PDF") == "pdf"
assert v.file_ext("a/b/x.png") == "png"
assert v.file_ext("noext") == ""
def test_valid_space_id():
assert v.valid_space_id("f1")
assert v.valid_space_id("floor-2_a")
assert not v.valid_space_id("Floor 1")
assert not v.valid_space_id("a" * 65)
assert not v.valid_space_id("../x")
def test_room_schema_poly_or_rect():
v.ROOM_SCHEMA({"id": "r1", "name": "A", "poly": [[0, 0], [1, 0], [1, 1]]})
v.ROOM_SCHEMA({"id": "r2", "name": "B", "x": 0, "y": 0, "w": 1, "h": 1})
with pytest.raises(vol.Invalid):
v.ROOM_SCHEMA({"id": "r3", "name": "C"})
with pytest.raises(vol.Invalid):
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
def test_space_schema_aspect_range():
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
v.SPACE_SCHEMA(ok)
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "aspect": 0})
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
def test_marker_schema():
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",
"pdfs": [{"name": "a.pdf", "url": "/u/a.pdf"}]})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({"binding": "virtual"})
def test_config_schema_defaults_and_extra():
out = v.CONFIG_SCHEMA({"spaces": []})
assert out["markers"] == [] and out["settings"] == {}
out2 = v.CONFIG_SCHEMA({"spaces": [], "virtual_devices": [], "device_overrides": {}})
assert "spaces" in out2
def test_config_schema_full_roundtrip():
cfg = {
"spaces": [{
"id": "f1", "title": "Floor 1", "plan_url": "/p/f1.svg",
"aspect": 0.8, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r1", "name": "Hall", "area": "hall",
"poly": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}],
"segments": [[0, 0, 0.5, 0]],
}],
"markers": [{"id": "d1", "binding": "device:x", "model": "M", "link": "https://e.com"}],
"settings": {"group_lights": True},
}
out = v.CONFIG_SCHEMA(cfg)
assert out["spaces"][0]["rooms"][0]["area"] == "hall"
assert out["markers"][0]["binding"] == "device:x"
def test_layout_schema():
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
with pytest.raises(vol.Invalid):
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "es2021",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2021", "dom", "dom.iterable"],
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"outDir": "tsout"
},
"include": ["src/**/*.ts"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
"include": ["src/logic.ts", "src/rules.ts"]
}