House Plan Card v1.0.0: Lovelace card (TS+Lit) + houseplan integration (server-side layout storage), HACS-ready

This commit is contained in:
JB
2026-07-03 19:09:24 +03:00
commit 0c6f37198f
28 changed files with 3310 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Release
on:
release:
types: [published]
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
Executable
+3
View File
@@ -0,0 +1,3 @@
node_modules/
tsout/
*.log
Executable
+68
View File
@@ -0,0 +1,68 @@
# House Plan Card — интерактивный план дома для Home Assistant
Нативная Lovelace-карточка «План дома» (дача, Кирилловское) + интеграция для серверного
хранения раскладки иконок. Замена прототипа `/local/houseplan/` (iframe + токен + localStorage).
- Работает **без токена** — использует объект `hass` фронтенда (сессия пользователя).
- План этажей (подложки РЕМПЛАННЕР 1489×1053, 1 ед. = 1 px) + двор, комнаты-зоны кликабельны.
- Устройства строятся **из реестров HA** (devices/entities/areas) с курированием и
группировкой ламп — те же правила, что в прототипе. Новое устройство с area появляется само.
- **Живые состояния**: температура у датчиков, подсветка вкл/выкл (свет/розетки/медиа),
открыто/закрыто (шторы/ворота/замки/датчики), приглушение при `unavailable`. Всё реактивно.
- Клик по иконке → штатное окно **more-info** (группа ламп → меню: вся группа / отдельные).
- Клик по комнате → страница зоны (`/config/areas/area/…`) без перезагрузки.
- **Правка**: кнопка ✥ — drag иконок + ввод X/Y; позиции сохраняются **на сервере**
(WS-команды интеграции `houseplan`) — раскладка одна на все устройства и браузеры.
Без интеграции карточка откатывается на localStorage.
## Установка (HACS)
1. HACS → меню (⋮) → **Custom repositories** → URL этого репозитория, категория **Integration**.
2. Установить «House Plan», перезапустить HA.
3. Настройки → Устройства и службы → **Добавить интеграцию → House Plan** (одна запись; опция
«правка только администраторами»). Интеграция сама раздаёт и подключает JS карточки —
ресурс Lovelace добавлять не нужно.
4. В дашборд добавить карточку:
```yaml
type: custom:houseplan-card
title: План дома · Кирилловское
default_floor: f1 # f1 | f2 | yard
icon_size: 22 # px
show_temperature: true
live_states: true
```
Только карточка (без серверного хранения): категория **Dashboard/Lovelace**, ресурс
`/hacsfiles/houseplan-card/houseplan-card.js` — позиции будут в localStorage браузера.
## WS-API интеграции
| Команда | Параметры | Действие |
|---|---|---|
| `houseplan/layout/get` | — | вернуть раскладку `{device_id: {x, y}}` |
| `houseplan/layout/set` | `layout` | заменить целиком |
| `houseplan/layout/update` | `device_id`, `pos` | обновить одну позицию |
Хранилище: `.storage/houseplan.layout` (helpers.storage.Store).
## Почему не ha-floorplan
Оценивали `ExperienceLovelace/ha-floorplan` (установлен в этом HA): он закрывает SVG-план,
привязку сущностей и стили по состоянию, но привязки статичны (entity → элемент SVG прописывается
руками в YAML/SVG), нет автопоявления новых устройств из реестра, нет курирования/группировки
ламп и главное — нет drag-раскладки с серверным сохранением. Перенос этих требований в
ha-floorplan потребовал бы генератор SVG+конфига и всё равно не дал бы редактирование из UI.
Своя карточка (~1 файл) оказалась дешевле и полностью повторяет прототип. Решение: **свой card**.
## Разработка
```bash
npm install
npm run build # dist/houseplan-card.js (+ скопировать в custom_components/houseplan/frontend/)
```
Геометрия комнат/viewBox — `src/data/house.ts`; подложки — `src/data/backgrounds.ts`
(генерируются из data.js прототипа); правила иконок/курирования — `src/rules.ts`.
Релиз: тег `vX.Y.Z` → GitHub Release; workflow соберёт и приложит `houseplan-card.js`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+64
View File
@@ -0,0 +1,64 @@
"""House Plan: серверное хранилище раскладки + раздача Lovelace-карточки."""
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, FRONTEND_URL, STORAGE_KEY, STORAGE_VERSION
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config) -> bool:
"""Регистрируем WS-команды и хранилище на старте."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
hp_ws.async_register(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config entry: статика фронтенда + авто-подключение 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"
if card_path.exists():
try:
from homeassistant.components.http import StaticPathConfig
await hass.http.async_register_static_paths(
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
)
except ImportError: # старые версии HA
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
add_extra_js_url(hass, f"{FRONTEND_URL}?v={_manifest_version(hass)}")
else:
_LOGGER.warning("houseplan-card.js не найден рядом с интеграцией: %s", card_path)
return True
def _manifest_version(hass: HomeAssistant) -> str:
try:
import json
manifest = Path(__file__).parent / "manifest.json"
return json.loads(manifest.read_text()).get("version", "0")
except Exception: # noqa: BLE001
return "0"
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
"""Config flow: одна запись без параметров."""
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):
"""Установка в один шаг."""
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):
"""Опция: правка раскладки только администраторами."""
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}),
)
+7
View File
@@ -0,0 +1,7 @@
"""Константы интеграции House Plan."""
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
CONF_ADMIN_ONLY = "admin_only"
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
{
"domain": "houseplan",
"name": "House Plan",
"codeowners": ["@justbusiness"],
"config_flow": true,
"dependencies": ["http", "frontend", "websocket_api"],
"documentation": "https://github.com/justbusiness/houseplan-card",
"integration_type": "service",
"iot_class": "local_push",
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
"requirements": [],
"version": "1.0.0"
}
+18
View File
@@ -0,0 +1,18 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" }
}
},
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Правка раскладки только администраторами" }
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" }
}
},
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Правка раскладки только администраторами" }
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
"""WS-команды houseplan/layout/get|set|update — серверное хранение раскладки."""
from __future__ import annotations
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, DOMAIN
POS_SCHEMA = vol.Schema({vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)})
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
@callback
def async_register(hass: HomeAssistant) -> None:
"""Регистрация WS-команд."""
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)
def _store(hass: HomeAssistant):
return hass.data[DOMAIN]["store"]
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
@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:
"""Вернуть сохранённую раскладку."""
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:
"""Полностью заменить раскладку."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам")
return
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:
"""Обновить позицию одного устройства."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам")
return
store = _store(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})
Vendored Executable
+403
View File
File diff suppressed because one or more lines are too long
Executable
+5
View File
@@ -0,0 +1,5 @@
{
"name": "House Plan (card + storage)",
"render_readme": true,
"homeassistant": "2024.6.0"
}
Generated Executable
+915
View File
@@ -0,0 +1,915 @@
{
"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"
}
}
}
}
Executable
+22
View File
@@ -0,0 +1,22 @@
{
"name": "houseplan-card",
"version": "1.0.0",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT",
"type": "module",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c --watch"
},
"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
@@ -0,0 +1,13 @@
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 } })],
};
+6
View File
File diff suppressed because one or more lines are too long
+140
View File
@@ -0,0 +1,140 @@
// Auto-generated from prototype data.js — geometry of house plan (1 unit = 1 px of 1489x1053 render)
export const IMG_W = 1489;
export const IMG_H = 1053;
export interface Room { floor: string; name: string; area: string; x: number; y: number; w: number; h: number; }
export const ROOMS: Room[] = [
{
"floor": "f1",
"name": "Гостиная",
"area": "gostinaia_na_dache",
"x": 510,
"y": 205,
"w": 430,
"h": 310
},
{
"floor": "f1",
"name": "Гостевой с/у",
"area": "gostevoi_s_u",
"x": 600,
"y": 515,
"w": 190,
"h": 170
},
{
"floor": "f1",
"name": "Котельная",
"area": "kotelnaia",
"x": 600,
"y": 690,
"w": 190,
"h": 180
},
{
"floor": "f1",
"name": "Кладовка уличная",
"area": "kladovka",
"x": 510,
"y": 690,
"w": 90,
"h": 180
},
{
"floor": "f1",
"name": "Прихожая",
"area": "prikhozhaia",
"x": 790,
"y": 600,
"w": 150,
"h": 270
},
{
"floor": "f1",
"name": "Гостевая спальня",
"area": "gostevaia_komnata",
"x": 945,
"y": 600,
"w": 255,
"h": 270
},
{
"floor": "f2",
"name": "Детская Майя",
"area": "detskaia_maiia",
"x": 365,
"y": 40,
"w": 435,
"h": 315
},
{
"floor": "f2",
"name": "Детская Элина",
"area": "detskaia_elina",
"x": 810,
"y": 40,
"w": 550,
"h": 315
},
{
"floor": "f2",
"name": "Детский с/у",
"area": "detskii_s_u",
"x": 390,
"y": 360,
"w": 260,
"h": 180
},
{
"floor": "f2",
"name": "Мастер с/у",
"area": "master_s_u",
"x": 390,
"y": 540,
"w": 260,
"h": 160
},
{
"floor": "f2",
"name": "Мастер-спальня",
"area": "spalnia",
"x": 650,
"y": 600,
"w": 330,
"h": 320
},
{
"floor": "f2",
"name": "Кабинет",
"area": "kabinet",
"x": 985,
"y": 600,
"w": 290,
"h": 320
},
{
"floor": "yard",
"name": "Двор / Участок",
"area": "dvor",
"x": 380,
"y": 120,
"w": 640,
"h": 760
}
];
export const FLOOR_VB: Record<string, number[]> = {"f1": [480, 30, 760, 950], "f2": [350, 20, 1040, 960], "yard": [340, 90, 720, 800]};
export const FLOOR_TITLES: Record<string, string> = {f1: '1 этаж', f2: '2 этаж', yard: 'Двор'};
export const AREA_NAMES: Record<string, string> = {
"gostinaia_na_dache": "Гостиная",
"gostevoi_s_u": "Гостевой с/у",
"kotelnaia": "Котельная",
"kladovka": "Кладовка уличная",
"prikhozhaia": "Прихожая",
"gostevaia_komnata": "Гостевая спальня",
"detskaia_maiia": "Детская Майя",
"detskaia_elina": "Детская Элина",
"detskii_s_u": "Детский с/у",
"master_s_u": "Мастер с/у",
"spalnia": "Мастер-спальня",
"kabinet": "Кабинет",
"dvor": "Двор / Участок"
};
Executable
+66
View File
@@ -0,0 +1,66 @@
/** Редактор конфигурации карточки (GUI в Lovelace). */
import { LitElement, html, nothing } from 'lit';
const SCHEMA = [
{ name: 'title', selector: { text: {} } },
{
name: 'default_floor',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'f1', label: '1 этаж' },
{ value: 'f2', label: '2 этаж' },
{ value: 'yard', label: 'Двор' },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 14, max: 48, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
];
const LABELS: Record<string, string> = {
title: 'Заголовок',
default_floor: 'Этаж по умолчанию',
icon_size: 'Размер иконок, px',
show_temperature: 'Показывать температуру',
live_states: 'Живые состояния (вкл/выкл, открыто…)',
};
class HouseplanCardEditor extends LitElement {
public hass?: any;
private _config?: any;
static properties = {
hass: { attribute: false },
_config: { state: true },
};
public setConfig(config: any): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) return nothing;
return html`<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${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);
}
+934
View File
@@ -0,0 +1,934 @@
/**
* House Plan Card интерактивный план дома (дача, Кирилловское) как нативная Lovelace-карточка.
* Работает от объекта `hass` (без токена). Раскладка иконок хранится на сервере
* через WS-команды интеграции `houseplan` (fallback localStorage).
*/
import { LitElement, html, css, nothing, TemplateResult, PropertyValues } from 'lit';
import { ROOMS, FLOOR_VB, FLOOR_TITLES, AREA_NAMES, IMG_W, IMG_H, Room } from './data/house';
import { FLOOR_BG } from './data/backgrounds';
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
import './editor';
const CARD_VERSION = '1.0.0';
const LS_KEY = 'houseplan_card_layout_v1';
interface DevItem {
id: string;
name: string;
model: string;
area: string;
floor: string;
icon: string;
entities: string[];
primary?: string;
temp?: number | null;
members?: { id: string; name: string; primary?: string }[];
link?: string | null;
linkPrimary?: string;
}
interface CardConfig {
type: string;
title?: string;
default_floor?: string;
icon_size?: number;
show_temperature?: boolean;
live_states?: boolean;
rooms?: Room[];
}
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
ev.detail = detail ?? {};
node.dispatchEvent(ev);
};
const navigate = (path: string) => {
history.pushState(null, '', path);
fireEvent(window, 'location-changed', { replace: false });
};
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number) => {
let t: number | undefined;
return (...a: Parameters<T>) => {
clearTimeout(t);
t = window.setTimeout(() => fn(...a), ms);
};
};
class HouseplanCard extends LitElement {
public hass?: any;
private _config?: CardConfig;
private _floor = 'f1';
private _edit = false;
private _layout: Record<string, { x: number; y: number }> = {};
private _serverStorage = false;
private _layoutLoaded = false;
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _menuDev: DevItem | null = null;
private _menuXY = { x: 0, y: 0 };
private _tip: { x: number; y: number; title: string; meta: string } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
// drag state
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
static properties = {
hass: { attribute: false },
_config: { state: true },
_floor: { state: true },
_edit: { state: true },
_layout: { state: true },
_devices: { state: true },
_menuDev: { state: true },
_tip: { state: true },
_selId: { state: true },
_toast: { state: true },
};
public static getConfigElement() {
return document.createElement('houseplan-card-editor');
}
public static getStubConfig(): Partial<CardConfig> {
return { type: 'custom:houseplan-card', title: 'План дома · Кирилловское' };
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 22, show_temperature: true, live_states: true, ...config };
if (config.default_floor) this._floor = config.default_floor;
}
public getCardSize(): number {
return 12;
}
private get _rooms(): Room[] {
return this._config?.rooms?.length ? this._config.rooms : ROOMS;
}
private get _areaSet(): Set<string> {
return new Set(this._rooms.filter((r) => r.area).map((r) => r.area));
}
private _areaRoom(area: string): Room | undefined {
return this._rooms.find((r) => r.area === area);
}
protected willUpdate(changed: PropertyValues): void {
if (changed.has('hass') && this.hass) {
if (!this._layoutLoaded) {
this._layoutLoaded = true;
this._loadLayout();
}
this._maybeRebuildDevices();
}
}
// ---------- раскладка: сервер (интеграция houseplan) или localStorage ----------
private async _loadLayout(): Promise<void> {
try {
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
this._layout = (resp && resp.layout) || {};
this._serverStorage = true;
} catch (e) {
this._serverStorage = false;
try {
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
} catch {
this._layout = {};
}
this._showToast('Интеграция houseplan не найдена — позиции сохраняются локально');
}
this.requestUpdate();
}
private _persistLayout = debounce(() => {
if (this._serverStorage) {
this.hass
.callWS({ type: 'houseplan/layout/set', layout: this._layout })
.catch((e: any) => this._showToast('Не удалось сохранить на сервере: ' + (e?.message || e)));
} else {
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
}
}, 600);
// ---------- построение списка устройств из реестров hass ----------
private _maybeRebuildDevices(): void {
const h = this.hass;
if (!h?.devices || !h?.entities || !h?.areas) return;
const sig =
Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' + Object.keys(h.areas).length;
if (sig === this._regSignature && this._devices.length) return;
this._regSignature = sig;
this._devices = this._buildDevices();
this._defPos = this._defaultPositions();
}
private _entitiesByDevice(): Record<string, string[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(this.hass.entities)) {
if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
}
return map;
}
private _domainOfDevice(dev: any, entIds: string[]): string {
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
for (const eid of entIds) {
const p = this.hass.entities[eid]?.platform;
if (p) return p;
}
return '';
}
private _primaryEntity(entIds: string[], icon: string): string | undefined {
const ents = entIds
.map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden);
const usable = ents.filter((e) => !e.reg.entity_category); // без diagnostic/config
const pool = usable.length ? usable : ents;
// датчик температуры — сразу температурная сущность
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => this._isTempEntity(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;
}
private _isTempEntity(eid: string): boolean {
const st = this.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)
);
}
private _tempFor(entIds: string[]): number | null {
for (const eid of entIds) {
if (!this._isTempEntity(eid)) continue;
const st = this.hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** Курирование + группировка — перенос buildDevices() из прототипа. */
private _buildDevices(): DevItem[] {
const h = this.hass;
const areaSet = this._areaSet;
const entsBy = this._entitiesByDevice();
const groupByArea: Record<string, any> = {};
for (const dev of Object.values<any>(h.devices)) {
if (dev.model === 'Group' && dev.area_id) groupByArea[dev.area_id] = dev;
}
const seen: Record<string, 1> = {};
const out: DevItem[] = [];
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaSet.has(area)) continue;
if (dev.entry_type === 'service') continue;
const entIds = entsBy[dev.id] || [];
const dom = this._domainOfDevice(dev, entIds);
if (EXCLUDED_DOMAINS.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 || 'без имени').trim();
const key = name + '|' + area;
if (seen[key]) continue;
seen[key] = 1;
const icon = iconFor(name, dev.model);
const room = this._areaRoom(area)!;
const item: DevItem = {
id: dev.id,
name,
model: dev.model || '',
area,
floor: room.floor,
icon,
entities: entIds,
};
item.primary = this._primaryEntity(entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
out.push(item);
}
// группировка ламп: ≥2 ламп в комнате → одна групповая иконка
const lamps: Record<string, DevItem[]> = {};
const rest: DevItem[] = [];
for (const d of out) {
if (d.icon === 'mdi:lightbulb') (lamps[d.area] = lamps[d.area] || []).push(d);
else rest.push(d);
}
for (const area of Object.keys(lamps)) {
const ms = lamps[area];
if (ms.length < 2) {
rest.push(...ms);
continue;
}
const grpDev = groupByArea[area];
const item: DevItem = {
id: 'grp_' + area,
name: GROUP_TITLES[area] || 'Лампы',
model: 'группа · ' + ms.length + ' шт',
area,
floor: this._areaRoom(area)!.floor,
icon: 'mdi:lightbulb-group',
entities: ms.flatMap((m) => m.entities),
link: grpDev?.id || null,
members: ms.map((m) => ({ id: m.id, name: m.name, primary: m.primary })),
};
if (grpDev) {
const grpEnts = this._entitiesByDevice()[grpDev.id] || [];
item.linkPrimary = this._primaryEntity(grpEnts, 'mdi:lightbulb');
}
rest.push(item);
}
return rest;
}
// ---------- позиции ----------
private _defaultPositions(): Record<string, { x: number; y: number }> {
const map: Record<string, { x: number; y: number }> = {};
for (const r of this._rooms) {
const ds = this._devices.filter((d) => d.area === r.area && d.floor === r.floor && r.area);
if (!ds.length) continue;
const pad = 20;
const iw = r.w - pad * 2;
const ih = r.h - pad * 2;
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
const rows = Math.ceil(ds.length / cols);
const cw = iw / cols;
const ch = ih / Math.max(rows, 1);
ds.forEach((d, i) => {
const c = i % cols;
const rw = Math.floor(i / cols);
map[d.id] = { x: r.x + pad + cw * (c + 0.5), y: r.y + pad + ch * (rw + 0.5) };
});
}
return map;
}
private _pos(d: DevItem): { x: number; y: number } {
const vb = FLOOR_VB[this._floor];
return this._layout[d.id] || this._defPos[d.id] || { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
}
// ---------- живые состояния ----------
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
const st = (eid?: string) => (eid ? this.hass.states[eid] : undefined);
const list = d.members
? (d.members.map((m) => st(m.primary)).filter(Boolean) as any[])
: ([st(d.primary)].filter(Boolean) as any[]);
if (!list.length) return '';
if (list.every((s) => s.state === 'unavailable')) return 'unavail';
const p = d.members ? list.find((s) => s.state === 'on') || list[0] : list[0];
const dom = p.entity_id.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
const dc = p.attributes?.device_class;
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
return p.state === 'on' ? 'open' : '';
}
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
return '';
}
private _liveTemp(d: DevItem): number | null {
if (!this._config?.show_temperature) return null;
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return this._tempFor(d.entities);
}
// ---------- взаимодействие ----------
private _openMoreInfo(entityId?: string): void {
if (!entityId) {
this._showToast('У устройства нет подходящей сущности');
return;
}
fireEvent(this, 'hass-more-info', { entityId });
}
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved) return;
if (this._edit) {
this._selId = d.id;
return;
}
if (d.members) {
this._menuDev = d;
this._menuXY = { x: ev.clientX, y: ev.clientY };
this._tip = null;
return;
}
this._openMoreInfo(d.primary);
}
private _clickRoom(r: Room): void {
if (this._edit) return;
navigate('/config/areas/area/' + r.area);
}
// drag
private _pointerDown(ev: PointerEvent, d: DevItem): void {
if (!this._edit) return;
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
if (!this._drag || this._drag.id !== d.id) return;
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
if (!stage) return;
const vb = FLOOR_VB[this._floor];
const rect = stage.getBoundingClientRect();
const dx = ((ev.clientX - this._drag.sx) / rect.width) * vb[2];
const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3];
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
let nx = Math.round(this._drag.ox + dx);
let ny = Math.round(this._drag.oy + dy);
nx = Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, nx));
ny = Math.max(vb[1] + 6, Math.min(vb[1] + vb[3] - 6, ny));
this._layout = { ...this._layout, [d.id]: { x: nx, y: ny } };
}
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
if (!this._drag || this._drag.id !== d.id) return;
const moved = this._drag.moved;
this._drag = moved ? this._drag : null; // click-обработчик проверит moved
if (moved) {
this._persistLayout();
this._selId = d.id;
window.setTimeout(() => (this._drag = null), 0);
}
}
private _applyXY(axis: 'x' | 'y', val: string): void {
if (!this._selId) return;
const n = parseInt(val, 10);
if (isNaN(n)) return;
const d = this._devices.find((x) => x.id === this._selId);
if (!d) return;
const vb = FLOOR_VB[this._floor];
const p = { ...this._pos(d) };
p[axis] = axis === 'x'
? Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, n))
: Math.max(vb[1] + 6, Math.min(vb[1] + vb[3] - 6, n));
this._layout = { ...this._layout, [d.id]: p };
this._persistLayout();
}
private _resetLayout(): void {
if (!confirm('Сбросить позиции всех иконок к авто-раскладке?')) return;
this._layout = {};
this._persistLayout();
}
private _showToast(msg: string): void {
this._toast = msg;
clearTimeout(this._toastTimer);
this._toastTimer = window.setTimeout(() => {
this._toast = '';
}, 3500);
}
private _showTip(ev: MouseEvent, title: string, meta: string): void {
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta };
}
// ---------- рендер ----------
protected render(): TemplateResult | typeof nothing {
if (!this._config || !this.hass) return nothing;
const vb = FLOOR_VB[this._floor];
const bg = FLOOR_BG[this._floor];
const rooms = this._rooms.filter((r) => r.floor === this._floor && r.area);
const devs = this._devices.filter((d) => d.floor === this._floor);
const iconSize = this._config.icon_size || 22;
return html`
<ha-card>
<div class="head">
<div class="title">
<ha-icon icon="mdi:home-city"></ha-icon>
${this._config.title || 'План дома'}
</div>
<div class="tabs">
${Object.keys(FLOOR_VB).map(
(f) => html`<button
class="tab ${this._floor === f ? 'active' : ''}"
@click=${() => {
this._floor = f;
this._selId = null;
}}
>
${FLOOR_TITLES[f] || f}
</button>`,
)}
</div>
<span class="count">${devs.length} устр.</span>
<span class="spacer"></span>
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
this._edit = !this._edit;
this._selId = null;
}} title="Режим правки: перетаскивание иконок">
<ha-icon icon="mdi:cursor-move"></ha-icon>
</button>
</div>
<div class="stage ${this._edit ? 'edit' : ''}" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
${bg
? html`<image href="${bg}" x="0" y="0" width="${IMG_W}" height="${IMG_H}" preserveAspectRatio="none" />`
: nothing}
${rooms.map(
(r) => html`<rect
class="room ${bg ? 'overlay' : 'yard'}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8"
@click=${() => this._clickRoom(r)}
@mousemove=${(e: MouseEvent) => this._showTip(e, r.name, 'комната — открыть зону')}
@mouseleave=${() => (this._tip = null)}
></rect>
${!bg ? html`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + 26}">${r.name}</text>` : nothing}`,
)}
</svg>
<div class="devlayer" style="--icon-size:${iconSize}px">
${devs.map((d) => this._renderDevice(d, vb))}
</div>
</div>
${this._edit ? this._renderEditbar() : nothing}
${this._menuDev ? this._renderMenu() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
</div>`
: nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>
`;
}
private _renderDevice(d: DevItem, vb: number[]): TemplateResult {
const p = this._pos(d);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const cls = this._stateClass(d);
const temp = this._liveTemp(d);
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name, d.model + (temp != null ? ' · ' + temp + '°' : ''))}
@mouseleave=${() => (this._tip = null)}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
>
<ha-icon icon="${d.icon}"></ha-icon>
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
</div>`;
}
private _renderEditbar(): TemplateResult {
const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null;
const p = sel ? this._pos(sel) : null;
return html`<div class="editbar">
<ha-icon icon="mdi:cursor-move" class="warn"></ha-icon>
<span class="sname">${sel ? sel.name : 'Режим правки — тащите иконки мышью'}</span>
${sel && p
? html`<label>X</label><input type="number" .value=${String(Math.round(p.x))}
@change=${(e: Event) => this._applyXY('x', (e.target as HTMLInputElement).value)} />
<label>Y</label><input type="number" .value=${String(Math.round(p.y))}
@change=${(e: Event) => this._applyXY('y', (e.target as HTMLInputElement).value)} />`
: nothing}
<span class="spacer"></span>
<span class="hint">${this._serverStorage ? 'сохранение: сервер (для всех)' : 'сохранение: этот браузер'}</span>
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
</div>`;
}
private _renderMenu(): TemplateResult {
const d = this._menuDev!;
return html`<div class="menuwrap" @click=${() => (this._menuDev = null)}>
<div class="menu" style="left:${this._menuXY.x}px;top:${this._menuXY.y}px" @click=${(e: Event) =>
e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:lightbulb-group"></ha-icon>${d.name} · ${d.members!.length}</div>
${d.linkPrimary
? html`<div class="it all" @click=${() => {
this._menuDev = null;
this._openMoreInfo(d.linkPrimary);
}}>
<ha-icon icon="mdi:lightbulb-group"></ha-icon>Открыть группу целиком
</div>`
: nothing}
${d.members!.map(
(m) => html`<div class="it" @click=${() => {
this._menuDev = null;
this._openMoreInfo(m.primary);
}}>
<ha-icon icon="mdi:lightbulb"></ha-icon>${m.name}
</div>`,
)}
</div>
</div>`;
}
static styles = 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: hidden;
}
.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;
}
.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;
}
.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;
}
.stage {
position: relative;
width: 100%;
}
.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;
}
.devlayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.dev {
position: absolute;
width: var(--icon-size, 22px);
height: var(--icon-size, 22px);
margin: calc(var(--icon-size, 22px) / -2) 0 0 calc(var(--icon-size, 22px) / -2);
border-radius: 5px;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: pointer;
pointer-events: auto;
transition: transform 0.08s, 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, 22px) * 0.62);
}
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
transform: scale(1.35);
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.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
}
.stage.edit .dev {
cursor: grab;
}
.dev .tval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 2px;
background: rgba(4, 18, 31, 0.9);
border: 1px solid var(--hp-accent);
border-radius: 4px;
padding: 0 3px;
font-size: 10px;
font-weight: 700;
line-height: 15px;
color: #dff1ff;
white-space: nowrap;
pointer-events: none;
}
.editbar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-top: 1px solid var(--hp-line);
font-size: 13px;
flex-wrap: wrap;
}
.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;
}
`;
}
if (!customElements.get('houseplan-card')) {
customElements.define('houseplan-card', HouseplanCard);
}
(window as any).customCards = (window as any).customCards || [];
if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-card')) {
(window as any).customCards.push({
type: 'houseplan-card',
name: 'House Plan Card',
description: 'Интерактивный план дома: этажи, комнаты, устройства с живыми состояниями и drag-раскладкой.',
});
}
// eslint-disable-next-line no-console
console.info(`%c HOUSEPLAN-CARD %c v${CARD_VERSION} `, 'background:#3ea6ff;color:#04121f;font-weight:700', '');
Executable
+64
View File
@@ -0,0 +1,64 @@
/**
* Правила прототипа перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.24.5).
*/
/** Интеграции-домены, чьи устройства скрываются (курирование). */
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',
]);
/** Заголовки групп ламп по area_id. */
export const GROUP_TITLES: Record<string, string> = {
gostinaia_na_dache: 'Настенные лампы',
prikhozhaia: 'Свет в прихожей',
detskaia_maiia: 'Свет у Майи',
detskaia_elina: 'Свет у Элины',
};
const ICON_RULES: Array<[string, 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', 'mdi:lock-smart'],
['ворота|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'],
];
/** Подбор MDI-иконки по имени/модели устройства. */
export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) {
if (new RegExp(pat).test(s)) return icon;
}
return 'mdi:chip';
}
/** Приоритет доменов для выбора «первичной» сущности устройства (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',
];
Executable
+16
View File
@@ -0,0 +1,16 @@
{
"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"]
}