mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.3.0 (phase 1): server-side config store (spaces/plans/rooms/overrides/virtual, normalized coords), WS config/get|set + plan/set upload, card resolved-model (server or legacy fallback), one-click migration — executed on dacha
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""House Plan: серверное хранилище раскладки + раздача Lovelace-карточки."""
|
"""House Plan: серверная конфигурация плана дома + раздача Lovelace-карточки."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,42 +10,59 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from . import websocket_api as hp_ws
|
from . import websocket_api as hp_ws
|
||||||
from .const import DOMAIN, FRONTEND_URL, STORAGE_KEY, STORAGE_VERSION, VERSION
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
FRONTEND_URL,
|
||||||
|
PLANS_DIR,
|
||||||
|
PLANS_URL,
|
||||||
|
STORAGE_CONFIG_KEY,
|
||||||
|
STORAGE_KEY,
|
||||||
|
STORAGE_VERSION,
|
||||||
|
VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
"""Регистрируем WS-команды и хранилище на старте."""
|
"""Регистрируем WS-команды и хранилища на старте."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||||
|
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
|
||||||
hp_ws.async_register(hass)
|
hp_ws.async_register(hass)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Config entry: статика фронтенда + авто-подключение JS."""
|
"""Config entry: статика фронтенда и планов + авто-подключение JS."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN]["entry"] = entry
|
hass.data[DOMAIN]["entry"] = entry
|
||||||
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
||||||
|
|
||||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||||
if card_path.exists():
|
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||||
try:
|
await hass.async_add_executor_job(lambda: plans_path.mkdir(parents=True, exist_ok=True))
|
||||||
from homeassistant.components.http import StaticPathConfig
|
|
||||||
|
|
||||||
await hass.http.async_register_static_paths(
|
static_paths = []
|
||||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
try:
|
||||||
)
|
from homeassistant.components.http import StaticPathConfig
|
||||||
except ImportError: # старые версии HA
|
|
||||||
|
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))
|
||||||
|
await hass.http.async_register_static_paths(static_paths)
|
||||||
|
except ImportError: # старые версии HA
|
||||||
|
if card_path.exists():
|
||||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||||
|
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||||
|
|
||||||
|
if card_path.exists():
|
||||||
add_extra_js_url(hass, f"{FRONTEND_URL}?v={VERSION}")
|
add_extra_js_url(hass, f"{FRONTEND_URL}?v={VERSION}")
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("houseplan-card.js не найден рядом с интеграцией: %s", card_path)
|
_LOGGER.warning("houseplan-card.js не найден рядом с интеграцией: %s", card_path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
hass.data[DOMAIN]["entry"] = entry
|
hass.data[DOMAIN]["entry"] = entry
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
BIN
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
DOMAIN = "houseplan"
|
DOMAIN = "houseplan"
|
||||||
STORAGE_KEY = f"{DOMAIN}.layout"
|
STORAGE_KEY = f"{DOMAIN}.layout"
|
||||||
|
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||||
|
PLANS_URL = "/houseplan_files/plans"
|
||||||
|
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
|
VERSION = "1.3.0"
|
||||||
|
|
||||||
VERSION = "1.0.0"
|
DEFAULT_CONFIG: dict = {
|
||||||
|
"spaces": [],
|
||||||
|
"device_overrides": {},
|
||||||
|
"virtual_devices": [],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.0.0"
|
"version": "1.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""WS-команды houseplan/layout/get|set|update — серверное хранение раскладки."""
|
"""WS-команды House Plan: раскладка, конфигурация пространств, загрузка планов."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -8,11 +12,73 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
from .const import CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, PLANS_DIR, PLANS_URL
|
||||||
|
|
||||||
POS_SCHEMA = vol.Schema({vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)})
|
POS_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
||||||
|
extra=vol.ALLOW_EXTRA, # v2-записи несут ключ "s" (space id)
|
||||||
|
)
|
||||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||||
|
|
||||||
|
ROOM_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("id"): str,
|
||||||
|
vol.Required("name"): str,
|
||||||
|
vol.Optional("area"): vol.Any(str, None),
|
||||||
|
vol.Required("x"): vol.Coerce(float),
|
||||||
|
vol.Required("y"): vol.Coerce(float),
|
||||||
|
vol.Required("w"): vol.Coerce(float),
|
||||||
|
vol.Required("h"): vol.Coerce(float),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
VIRTUAL_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("id"): str,
|
||||||
|
vol.Required("space"): str,
|
||||||
|
vol.Required("name"): str,
|
||||||
|
vol.Required("icon"): str,
|
||||||
|
vol.Required("x"): vol.Coerce(float),
|
||||||
|
vol.Required("y"): vol.Coerce(float),
|
||||||
|
vol.Optional("note"): vol.Any(str, None),
|
||||||
|
vol.Optional("entity_id"): vol.Any(str, None),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||||
|
vol.Optional("device_overrides", default=dict): {
|
||||||
|
str: vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("hidden"): bool,
|
||||||
|
vol.Optional("icon"): vol.Any(str, None),
|
||||||
|
vol.Optional("name"): vol.Any(str, None),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
vol.Optional("virtual_devices", default=list): [VIRTUAL_SCHEMA],
|
||||||
|
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||||
|
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||||
|
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_register(hass: HomeAssistant) -> None:
|
def async_register(hass: HomeAssistant) -> None:
|
||||||
@@ -20,18 +86,28 @@ def async_register(hass: HomeAssistant) -> None:
|
|||||||
websocket_api.async_register_command(hass, ws_layout_get)
|
websocket_api.async_register_command(hass, ws_layout_get)
|
||||||
websocket_api.async_register_command(hass, ws_layout_set)
|
websocket_api.async_register_command(hass, ws_layout_set)
|
||||||
websocket_api.async_register_command(hass, ws_layout_update)
|
websocket_api.async_register_command(hass, ws_layout_update)
|
||||||
|
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):
|
def _store(hass: HomeAssistant):
|
||||||
return hass.data[DOMAIN]["store"]
|
return hass.data[DOMAIN]["store"]
|
||||||
|
|
||||||
|
|
||||||
|
def _config_store(hass: HomeAssistant):
|
||||||
|
return hass.data[DOMAIN]["config_store"]
|
||||||
|
|
||||||
|
|
||||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||||
entry = hass.data[DOMAIN].get("entry")
|
entry = hass.data[DOMAIN].get("entry")
|
||||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||||
return connection.user.is_admin if admin_only else True
|
return connection.user.is_admin if admin_only else True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- раскладка ----------------
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
@@ -72,3 +148,77 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
layout[msg["device_id"]] = msg["pos"]
|
layout[msg["device_id"]] = msg["pos"]
|
||||||
await store.async_save({"layout": layout})
|
await store.async_save({"layout": layout})
|
||||||
connection.send_result(msg["id"], {"ok": True})
|
connection.send_result(msg["id"], {"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- конфигурация пространств ----------------
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""Вернуть конфигурацию (пространства, оверрайды, виртуальные устройства, настройки)."""
|
||||||
|
data = await _config_store(hass).async_load() or {}
|
||||||
|
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||||
|
connection.send_result(msg["id"], {"config": config})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{vol.Required("type"): "houseplan/config/set", vol.Required("config"): CONFIG_SCHEMA}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
|
"""Полностью заменить конфигурацию."""
|
||||||
|
if not _check_write(hass, connection):
|
||||||
|
connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам")
|
||||||
|
return
|
||||||
|
await _config_store(hass).async_save({"config": msg["config"]})
|
||||||
|
connection.send_result(msg["id"], {"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- загрузка планов ----------------
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""Сохранить файл плана пространства; вернуть URL для карточки."""
|
||||||
|
if not _check_write(hass, connection):
|
||||||
|
connection.send_error(msg["id"], "unauthorized", "Загрузка планов разрешена только администраторам")
|
||||||
|
return
|
||||||
|
space_id = msg["space_id"]
|
||||||
|
if not SPACE_ID_RE.match(space_id):
|
||||||
|
connection.send_error(msg["id"], "invalid_space_id", "space_id: только [a-z0-9_-], до 64 символов")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(msg["data"], validate=True)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
connection.send_error(msg["id"], "invalid_data", "data должен быть корректным base64")
|
||||||
|
return
|
||||||
|
if len(raw) > MAX_PLAN_BYTES:
|
||||||
|
connection.send_error(msg["id"], "too_large", f"План больше {MAX_PLAN_BYTES // 1024 // 1024} МБ")
|
||||||
|
return
|
||||||
|
|
||||||
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
|
path = plans_dir / f"{space_id}.{msg['ext']}"
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# убрать старые варианты с другим расширением
|
||||||
|
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)
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(_write)
|
||||||
|
mtime = int(path.stat().st_mtime)
|
||||||
|
connection.send_result(
|
||||||
|
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
|
||||||
|
)
|
||||||
|
|||||||
Vendored
+56
-38
File diff suppressed because one or more lines are too long
@@ -83,6 +83,21 @@ more-info по приоритету доменов), temp, members[] (групп
|
|||||||
`.head { position: sticky; top: var(--header-height, 56px) }`; ОБЯЗАТЕЛЬНО
|
`.head { position: sticky; top: var(--header-height, 56px) }`; ОБЯЗАТЕЛЬНО
|
||||||
`ha-card { overflow: visible }` — `overflow: hidden` ломает sticky.
|
`ha-card { overflow: visible }` — `overflow: hidden` ломает sticky.
|
||||||
|
|
||||||
|
## Серверная конфигурация (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} }
|
||||||
|
```
|
||||||
|
Все координаты **нормированные (0..1 от плана пространства)**; рендер-пространство
|
||||||
|
1000 × 1000/aspect. Раскладка v2: `{device_id: {"s": space, "x", "y"}}` (нормир.).
|
||||||
|
Файлы планов: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
|
||||||
|
Если server config пуст — карточка работает от legacy-бандла (дача) и показывает кнопку
|
||||||
|
миграции «На сервер» в режиме правки. Дача мигрирована 2026-07-04.
|
||||||
|
|
||||||
## WS API интеграции
|
## WS API интеграции
|
||||||
|
|
||||||
| Команда | Параметры | Ответ |
|
| Команда | Параметры | Ответ |
|
||||||
@@ -90,3 +105,6 @@ more-info по приоритету доменов), temp, members[] (групп
|
|||||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
||||||
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only опционально) |
|
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only опционально) |
|
||||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||||
|
| `houseplan/config/get` | — | `{config}` |
|
||||||
|
| `houseplan/config/set` | `config` (валидация vol) | `{ok}` |
|
||||||
|
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8МБ) | `{ok, url}` |
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.3.0 — 2026-07-04 (Фаза 1: серверная конфигурация)
|
||||||
|
- Store `houseplan.config`: spaces (план, aspect, view_box, комнаты), device_overrides
|
||||||
|
(hidden/icon/name), virtual_devices, settings (exclude_integrations, group_lights).
|
||||||
|
- WS: `houseplan/config/get|set`, `houseplan/plan/set` (загрузка файла плана base64 →
|
||||||
|
`<config>/houseplan/plans/`, отдача через /houseplan_files/plans/).
|
||||||
|
- Карточка: резолв-модель — server config (координаты НОРМИРОВАННЫЕ 0..1, рендер 1000×1000/aspect)
|
||||||
|
или legacy-бандл (холст 1489×1053). Раскладка v2: {device_id: {s, x, y}} нормированная.
|
||||||
|
- Кнопка «На сервер» в режиме правки — миграция legacy-конфига в один клик (планы, комнаты,
|
||||||
|
view_box, раскладка). Выполнена на даче: .storage/houseplan.config + plans/f1.svg,f2.svg.
|
||||||
|
- Read-side поддержка оверрайдов устройств и виртуальных устройств (UI управления — фазы 3–4).
|
||||||
|
|
||||||
## v1.2.2 — 2026-07-04
|
## v1.2.2 — 2026-07-04
|
||||||
- Тулбар карточки (вкладки этажей) закрепляется при скролле под шапкой HA
|
- Тулбар карточки (вкладки этажей) закрепляется при скролле под шапкой HA
|
||||||
(`position: sticky; top: var(--header-height)`; ha-card overflow: visible).
|
(`position: sticky; top: var(--header-height)`; ha-card overflow: visible).
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
- [ ] README EN (основной) + README.ru.md; скриншоты/GIF (обязательны для витрины HACS)
|
- [ ] README EN (основной) + README.ru.md; скриншоты/GIF (обязательны для витрины HACS)
|
||||||
- [ ] Заменить codeowners/documentation/issue_tracker на реальные URL после создания репо
|
- [ ] Заменить codeowners/documentation/issue_tracker на реальные URL после создания репо
|
||||||
|
|
||||||
## Фаза 1 — Конфиг на сервере (декаплинг от дачи) ← ФУНДАМЕНТ
|
## Фаза 1 — Конфиг на сервере (декаплинг от дачи) ← ✅ СДЕЛАНО в v1.3.0 (кроме выпиливания бандл-данных — оставлены как fallback до фазы 2)
|
||||||
Новое хранилище `houseplan.config` (Store v1):
|
Новое хранилище `houseplan.config` (Store v1):
|
||||||
```json
|
```json
|
||||||
{ "spaces": [ { "id": "f1", "title": "1 этаж", "plan": {"media_id": "...", "type": "svg"},
|
{ "spaces": [ { "id": "f1", "title": "1 этаж", "plan": {"media_id": "...", "type": "svg"},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+420
-136
@@ -1,23 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* House Plan Card — интерактивный план дома (дача, Кирилловское) как нативная Lovelace-карточка.
|
* House Plan Card — интерактивный план дома как нативная Lovelace-карточка.
|
||||||
* Работает от объекта `hass` (без токена). Раскладка иконок хранится на сервере
|
* Источники конфигурации:
|
||||||
* через WS-команды интеграции `houseplan` (fallback — localStorage).
|
* 1) СЕРВЕР (интеграция houseplan, WS houseplan/config/get) — пространства, планы,
|
||||||
|
* комнаты, оверрайды устройств, виртуальные устройства. Координаты НОРМИРОВАННЫЕ (0..1).
|
||||||
|
* 2) LEGACY-фолбэк — вшитые данные дачи (src/data/*), координаты в холсте 1489×1053.
|
||||||
|
* Раскладка иконок хранится на сервере (houseplan/layout/*), fallback — localStorage.
|
||||||
*/
|
*/
|
||||||
import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit';
|
import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||||
import { ROOMS, FLOOR_VB, FLOOR_TITLES, AREA_NAMES, Room } from './data/house';
|
import { ROOMS, FLOOR_VB, FLOOR_TITLES, Room } from './data/house';
|
||||||
import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
||||||
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||||
import './editor';
|
import './editor';
|
||||||
|
|
||||||
const CARD_VERSION = '1.2.2';
|
const CARD_VERSION = '1.3.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
|
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||||
|
|
||||||
|
// Натуральные размеры legacy-планов (для миграции): SVG viewBox РЕМПЛАННЕР
|
||||||
|
const LEGACY_PLAN_SIZE: Record<string, [number, number]> = {
|
||||||
|
f1: [1196.6656, 1467.26],
|
||||||
|
f2: [1170.0986, 1073],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoomCfg {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
area: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualDevice {
|
||||||
|
id: string;
|
||||||
|
space: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
x: number; // normalized
|
||||||
|
y: number;
|
||||||
|
note?: string | null;
|
||||||
|
entity_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerConfig {
|
||||||
|
spaces: any[];
|
||||||
|
device_overrides: Record<string, { hidden?: boolean; icon?: string | null; name?: string | null }>;
|
||||||
|
virtual_devices: VirtualDevice[];
|
||||||
|
settings: { exclude_integrations?: string[]; group_lights?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
interface DevItem {
|
interface DevItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
model: string;
|
model: string;
|
||||||
area: string;
|
area: string;
|
||||||
floor: string;
|
space: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
entities: string[];
|
entities: string[];
|
||||||
primary?: string;
|
primary?: string;
|
||||||
@@ -25,6 +71,7 @@ interface DevItem {
|
|||||||
members?: { id: string; name: string; primary?: string }[];
|
members?: { id: string; name: string; primary?: string }[];
|
||||||
link?: string | null;
|
link?: string | null;
|
||||||
linkPrimary?: string;
|
linkPrimary?: string;
|
||||||
|
virtual?: VirtualDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardConfig {
|
interface CardConfig {
|
||||||
@@ -67,11 +114,12 @@ class HouseplanCard extends LitElement {
|
|||||||
public hass?: any;
|
public hass?: any;
|
||||||
private _config?: CardConfig;
|
private _config?: CardConfig;
|
||||||
|
|
||||||
private _floor = 'f1';
|
private _space = 'f1';
|
||||||
private _edit = false;
|
private _edit = false;
|
||||||
private _layout: Record<string, { x: number; y: number }> = {};
|
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
|
||||||
private _serverStorage = false;
|
private _serverStorage = false;
|
||||||
private _layoutLoaded = false;
|
private _loaded = false;
|
||||||
|
private _serverCfg: ServerConfig | null = null;
|
||||||
private _devices: DevItem[] = [];
|
private _devices: DevItem[] = [];
|
||||||
private _regSignature = '';
|
private _regSignature = '';
|
||||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||||
@@ -81,14 +129,14 @@ class HouseplanCard extends LitElement {
|
|||||||
private _selId: string | null = null;
|
private _selId: string | null = null;
|
||||||
private _toast = '';
|
private _toast = '';
|
||||||
private _toastTimer?: number;
|
private _toastTimer?: number;
|
||||||
|
private _migrating = false;
|
||||||
|
|
||||||
// drag state
|
|
||||||
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
||||||
|
|
||||||
static properties = {
|
static properties = {
|
||||||
hass: { attribute: false },
|
hass: { attribute: false },
|
||||||
_config: { state: true },
|
_config: { state: true },
|
||||||
_floor: { state: true },
|
_space: { state: true },
|
||||||
_edit: { state: true },
|
_edit: { state: true },
|
||||||
_layout: { state: true },
|
_layout: { state: true },
|
||||||
_devices: { state: true },
|
_devices: { state: true },
|
||||||
@@ -96,6 +144,8 @@ class HouseplanCard extends LitElement {
|
|||||||
_tip: { state: true },
|
_tip: { state: true },
|
||||||
_selId: { state: true },
|
_selId: { state: true },
|
||||||
_toast: { state: true },
|
_toast: { state: true },
|
||||||
|
_serverCfg: { state: true },
|
||||||
|
_migrating: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static getConfigElement() {
|
public static getConfigElement() {
|
||||||
@@ -103,48 +153,112 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static getStubConfig(): Partial<CardConfig> {
|
public static getStubConfig(): Partial<CardConfig> {
|
||||||
return { type: 'custom:houseplan-card', title: 'План дома · Кирилловское' };
|
return { type: 'custom:houseplan-card', title: 'План дома' };
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConfig(config: CardConfig): void {
|
public setConfig(config: CardConfig): void {
|
||||||
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
|
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
|
||||||
if (config.default_floor) this._floor = config.default_floor;
|
if (config.default_floor) this._space = config.default_floor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCardSize(): number {
|
public getCardSize(): number {
|
||||||
return 12;
|
return 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _rooms(): Room[] {
|
// ================= РЕЗОЛВ МОДЕЛИ (сервер или legacy) =================
|
||||||
return this._config?.rooms?.length ? this._config.rooms : ROOMS;
|
|
||||||
|
private get _norm(): boolean {
|
||||||
|
return !!(this._serverCfg && this._serverCfg.spaces.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _areaSet(): Set<string> {
|
/** Пространства в рендер-единицах: сервер → NORM_W×NORM_W/aspect; legacy → холст 1489×1053. */
|
||||||
return new Set(this._rooms.filter((r) => r.area).map((r) => r.area));
|
private get _model(): SpaceModel[] {
|
||||||
|
if (this._norm) {
|
||||||
|
return this._serverCfg!.spaces.map((s: any) => {
|
||||||
|
const H = NORM_W / s.aspect;
|
||||||
|
const scale = (r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
area: r.area ?? null,
|
||||||
|
x: r.x * NORM_W,
|
||||||
|
y: r.y * H,
|
||||||
|
w: r.w * NORM_W,
|
||||||
|
h: r.h * H,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||||
|
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||||
|
rooms: s.rooms.map(scale),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// legacy: вшитые данные
|
||||||
|
const legacyRooms = this._config?.rooms?.length ? this._config.rooms : ROOMS;
|
||||||
|
return Object.keys(FLOOR_VB).map((fl) => {
|
||||||
|
const rect = FLOOR_BG_RECT[fl];
|
||||||
|
const bgHref = FLOOR_BG[fl];
|
||||||
|
return {
|
||||||
|
id: fl,
|
||||||
|
title: FLOOR_TITLES[fl] || fl,
|
||||||
|
vb: FLOOR_VB[fl],
|
||||||
|
bg: bgHref && rect ? { href: bgHref, x: rect[0], y: rect[1], w: rect[2], h: rect[3] } : null,
|
||||||
|
rooms: legacyRooms
|
||||||
|
.filter((r) => r.floor === fl)
|
||||||
|
.map((r) => ({ name: r.name, area: r.area || null, x: r.x, y: r.y, w: r.w, h: r.h })),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _areaRoom(area: string): Room | undefined {
|
private _spaceModel(id?: string): SpaceModel {
|
||||||
return this._rooms.find((r) => r.area === area);
|
const m = this._model;
|
||||||
|
return m.find((s) => s.id === (id ?? this._space)) || m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _areaToSpace(): Record<string, { space: string; room: RoomCfg }> {
|
||||||
|
const map: Record<string, { space: string; room: RoomCfg }> = {};
|
||||||
|
for (const s of this._model) for (const r of s.rooms) if (r.area) map[r.area] = { space: s.id, room: r };
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _settings(): ServerConfig['settings'] {
|
||||||
|
return this._serverCfg?.settings || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _excluded(): Set<string> {
|
||||||
|
const list = this._settings.exclude_integrations;
|
||||||
|
return list ? new Set(list) : EXCLUDED_DOMAINS;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changed: PropertyValues): void {
|
protected willUpdate(changed: PropertyValues): void {
|
||||||
if (changed.has('hass') && this.hass) {
|
if (changed.has('hass') && this.hass) {
|
||||||
if (!this._layoutLoaded) {
|
if (!this._loaded) {
|
||||||
this._layoutLoaded = true;
|
this._loaded = true;
|
||||||
this._loadLayout();
|
this._loadFromServer();
|
||||||
}
|
}
|
||||||
this._maybeRebuildDevices();
|
this._maybeRebuildDevices();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- раскладка: сервер (интеграция houseplan) или localStorage ----------
|
// ================= сервер: конфиг + раскладка =================
|
||||||
private async _loadLayout(): Promise<void> {
|
|
||||||
|
private async _loadFromServer(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
const [cfgResp, layResp] = await Promise.all([
|
||||||
this._layout = (resp && resp.layout) || {};
|
this.hass.callWS({ type: 'houseplan/config/get' }),
|
||||||
|
this.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||||
|
]);
|
||||||
this._serverStorage = true;
|
this._serverStorage = true;
|
||||||
|
const cfg = cfgResp?.config;
|
||||||
|
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||||
|
this._layout = layResp?.layout || {};
|
||||||
|
if (this._norm && !this._model.find((s) => s.id === this._space)) {
|
||||||
|
this._space = this._model[0]?.id || this._space;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._serverStorage = false;
|
this._serverStorage = false;
|
||||||
|
this._serverCfg = null;
|
||||||
try {
|
try {
|
||||||
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
|
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,6 +266,7 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
this._showToast('Интеграция houseplan не найдена — позиции сохраняются локально');
|
this._showToast('Интеграция houseplan не найдена — позиции сохраняются локально');
|
||||||
}
|
}
|
||||||
|
this._regSignature = '';
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,12 +280,14 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
// ---------- построение списка устройств из реестров hass ----------
|
// ================= устройства из реестров =================
|
||||||
|
|
||||||
private _maybeRebuildDevices(): void {
|
private _maybeRebuildDevices(): void {
|
||||||
const h = this.hass;
|
const h = this.hass;
|
||||||
if (!h?.devices || !h?.entities || !h?.areas) return;
|
if (!h?.devices || !h?.entities || !h?.areas) return;
|
||||||
const sig =
|
const sig =
|
||||||
Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' + Object.keys(h.areas).length;
|
Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' +
|
||||||
|
Object.keys(h.areas).length + ':' + (this._norm ? 'n' : 'l');
|
||||||
if (sig === this._regSignature && this._devices.length) return;
|
if (sig === this._regSignature && this._devices.length) return;
|
||||||
this._regSignature = sig;
|
this._regSignature = sig;
|
||||||
this._devices = this._buildDevices();
|
this._devices = this._buildDevices();
|
||||||
@@ -198,9 +315,8 @@ class HouseplanCard extends LitElement {
|
|||||||
const ents = entIds
|
const ents = entIds
|
||||||
.map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] }))
|
.map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] }))
|
||||||
.filter((e) => e.reg && !e.reg.hidden);
|
.filter((e) => e.reg && !e.reg.hidden);
|
||||||
const usable = ents.filter((e) => !e.reg.entity_category); // без diagnostic/config
|
const usable = ents.filter((e) => !e.reg.entity_category);
|
||||||
const pool = usable.length ? usable : ents;
|
const pool = usable.length ? usable : ents;
|
||||||
// датчик температуры — сразу температурная сущность
|
|
||||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
|
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
|
||||||
const t = pool.find((e) => this._isTempEntity(e.eid));
|
const t = pool.find((e) => this._isTempEntity(e.eid));
|
||||||
if (t) return t.eid;
|
if (t) return t.eid;
|
||||||
@@ -221,7 +337,6 @@ class HouseplanCard extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LQI zigbee-устройства: среднее по всем *_linkquality сущностям (для группы — по участникам). */
|
|
||||||
private _lqiFor(entIds: string[]): number | null {
|
private _lqiFor(entIds: string[]): number | null {
|
||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
for (const eid of entIds) {
|
for (const eid of entIds) {
|
||||||
@@ -235,11 +350,11 @@ class HouseplanCard extends LitElement {
|
|||||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Средний LQI zigbee-устройств комнаты. */
|
private _roomLqi(area: string | null): number | null {
|
||||||
private _roomLqi(area: string): number | null {
|
if (!area) return null;
|
||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
for (const d of this._devices) {
|
for (const d of this._devices) {
|
||||||
if (d.area !== area) continue;
|
if (d.area !== area || d.virtual) continue;
|
||||||
const l = this._lqiFor(d.entities);
|
const l = this._lqiFor(d.entities);
|
||||||
if (l != null) vals.push(l);
|
if (l != null) vals.push(l);
|
||||||
}
|
}
|
||||||
@@ -258,10 +373,13 @@ class HouseplanCard extends LitElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Курирование + группировка — перенос buildDevices() из прототипа. */
|
/** Курирование + группировка + оверрайды + виртуальные устройства. */
|
||||||
private _buildDevices(): DevItem[] {
|
private _buildDevices(): DevItem[] {
|
||||||
const h = this.hass;
|
const h = this.hass;
|
||||||
const areaSet = this._areaSet;
|
const areaMap = this._areaToSpace;
|
||||||
|
const overrides = this._serverCfg?.device_overrides || {};
|
||||||
|
const groupLights = this._settings.group_lights !== false;
|
||||||
|
const excluded = this._excluded;
|
||||||
const entsBy = this._entitiesByDevice();
|
const entsBy = this._entitiesByDevice();
|
||||||
const groupByArea: Record<string, any> = {};
|
const groupByArea: Record<string, any> = {};
|
||||||
for (const dev of Object.values<any>(h.devices)) {
|
for (const dev of Object.values<any>(h.devices)) {
|
||||||
@@ -271,29 +389,30 @@ class HouseplanCard extends LitElement {
|
|||||||
const out: DevItem[] = [];
|
const out: DevItem[] = [];
|
||||||
for (const dev of Object.values<any>(h.devices)) {
|
for (const dev of Object.values<any>(h.devices)) {
|
||||||
const area = dev.area_id;
|
const area = dev.area_id;
|
||||||
if (!area || !areaSet.has(area)) continue;
|
if (!area || !areaMap[area]) continue;
|
||||||
if (dev.entry_type === 'service') continue;
|
if (dev.entry_type === 'service') continue;
|
||||||
|
const ov = overrides[dev.id] || {};
|
||||||
|
if (ov.hidden) continue;
|
||||||
const entIds = entsBy[dev.id] || [];
|
const entIds = entsBy[dev.id] || [];
|
||||||
const dom = this._domainOfDevice(dev, entIds);
|
const dom = this._domainOfDevice(dev, entIds);
|
||||||
if (EXCLUDED_DOMAINS.has(dom)) continue;
|
if (excluded.has(dom)) continue;
|
||||||
if (dev.model === 'Group') continue;
|
if (dev.model === 'Group') continue;
|
||||||
if (/scene/i.test(dev.model || '')) continue;
|
if (/scene/i.test(dev.model || '')) continue;
|
||||||
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
|
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
|
||||||
if (dom === 'myheat' && dev.via_device_id) continue;
|
if (dom === 'myheat' && dev.via_device_id) continue;
|
||||||
const name = (dev.name_by_user || dev.name || 'без имени').trim();
|
const name = (ov.name || dev.name_by_user || dev.name || 'без имени').trim();
|
||||||
const key = name + '|' + area;
|
const key = name + '|' + area;
|
||||||
if (seen[key]) continue;
|
if (seen[key]) continue;
|
||||||
seen[key] = 1;
|
seen[key] = 1;
|
||||||
let icon = iconFor(name, dev.model);
|
let icon = iconFor(name, dev.model);
|
||||||
// устройство с сущностью lock.* — всегда «замочек», как бы оно ни называлось
|
|
||||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||||
const room = this._areaRoom(area)!;
|
if (ov.icon) icon = ov.icon;
|
||||||
const item: DevItem = {
|
const item: DevItem = {
|
||||||
id: dev.id,
|
id: dev.id,
|
||||||
name,
|
name,
|
||||||
model: dev.model || '',
|
model: dev.model || '',
|
||||||
area,
|
area,
|
||||||
floor: room.floor,
|
space: areaMap[area].space,
|
||||||
icon,
|
icon,
|
||||||
entities: entIds,
|
entities: entIds,
|
||||||
};
|
};
|
||||||
@@ -301,68 +420,123 @@ class HouseplanCard extends LitElement {
|
|||||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
|
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
|
||||||
out.push(item);
|
out.push(item);
|
||||||
}
|
}
|
||||||
// группировка ламп: ≥2 ламп в комнате → одна групповая иконка
|
// группировка ламп
|
||||||
const lamps: Record<string, DevItem[]> = {};
|
|
||||||
const rest: DevItem[] = [];
|
const rest: DevItem[] = [];
|
||||||
for (const d of out) {
|
if (groupLights) {
|
||||||
if (d.icon === 'mdi:lightbulb') (lamps[d.area] = lamps[d.area] || []).push(d);
|
const lamps: Record<string, DevItem[]> = {};
|
||||||
else rest.push(d);
|
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,
|
||||||
|
space: areaMap[area].space,
|
||||||
|
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 = entsBy[grpDev.id] || [];
|
||||||
|
item.linkPrimary = this._primaryEntity(grpEnts, 'mdi:lightbulb');
|
||||||
|
}
|
||||||
|
rest.push(item);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rest.push(...out);
|
||||||
}
|
}
|
||||||
for (const area of Object.keys(lamps)) {
|
// виртуальные устройства
|
||||||
const ms = lamps[area];
|
for (const v of this._serverCfg?.virtual_devices || []) {
|
||||||
if (ms.length < 2) {
|
rest.push({
|
||||||
rest.push(...ms);
|
id: 'virt_' + v.id,
|
||||||
continue;
|
name: v.name,
|
||||||
}
|
model: v.note || 'виртуальное устройство',
|
||||||
const grpDev = groupByArea[area];
|
area: '',
|
||||||
const item: DevItem = {
|
space: v.space,
|
||||||
id: 'grp_' + area,
|
icon: v.icon,
|
||||||
name: GROUP_TITLES[area] || 'Лампы',
|
entities: v.entity_id ? [v.entity_id] : [],
|
||||||
model: 'группа · ' + ms.length + ' шт',
|
primary: v.entity_id || undefined,
|
||||||
area,
|
virtual: v,
|
||||||
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;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- позиции ----------
|
// ================= позиции =================
|
||||||
|
|
||||||
private _defaultPositions(): Record<string, { x: number; y: number }> {
|
private _defaultPositions(): Record<string, { x: number; y: number }> {
|
||||||
const map: Record<string, { x: number; y: number }> = {};
|
const map: Record<string, { x: number; y: number }> = {};
|
||||||
for (const r of this._rooms) {
|
for (const s of this._model) {
|
||||||
const ds = this._devices.filter((d) => d.area === r.area && d.floor === r.floor && r.area);
|
for (const r of s.rooms) {
|
||||||
if (!ds.length) continue;
|
if (!r.area) continue;
|
||||||
const pad = 20;
|
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id);
|
||||||
const iw = r.w - pad * 2;
|
if (!ds.length) continue;
|
||||||
const ih = r.h - pad * 2;
|
const pad = Math.min(r.w, r.h) * 0.12;
|
||||||
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
|
const iw = r.w - pad * 2;
|
||||||
const rows = Math.ceil(ds.length / cols);
|
const ih = r.h - pad * 2;
|
||||||
const cw = iw / cols;
|
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
|
||||||
const ch = ih / Math.max(rows, 1);
|
const rows = Math.ceil(ds.length / cols);
|
||||||
ds.forEach((d, i) => {
|
const cw = iw / cols;
|
||||||
const c = i % cols;
|
const ch = ih / Math.max(rows, 1);
|
||||||
const rw = Math.floor(i / cols);
|
ds.forEach((d, i) => {
|
||||||
map[d.id] = { x: r.x + pad + cw * (c + 0.5), y: r.y + pad + ch * (rw + 0.5) };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Позиция устройства в рендер-единицах текущего пространства. */
|
||||||
private _pos(d: DevItem): { x: number; y: number } {
|
private _pos(d: DevItem): { x: number; y: number } {
|
||||||
const vb = FLOOR_VB[this._floor];
|
const s = this._spaceModel(d.space);
|
||||||
return this._layout[d.id] || this._defPos[d.id] || { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
|
const H = s.vb ? undefined : undefined;
|
||||||
|
if (d.virtual) {
|
||||||
|
const height = this._norm ? NORM_W / (this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1) : 0;
|
||||||
|
return { x: d.virtual.x * (this._norm ? NORM_W : 1), y: d.virtual.y * (this._norm ? height : 1) };
|
||||||
|
}
|
||||||
|
const saved = this._layout[d.id];
|
||||||
|
if (saved) {
|
||||||
|
if (this._norm) {
|
||||||
|
if (saved.s === d.space) {
|
||||||
|
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||||
|
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||||
|
}
|
||||||
|
} else if (saved.s === undefined) {
|
||||||
|
return { x: saved.x, y: saved.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._defPos[d.id]) return this._defPos[d.id];
|
||||||
|
const vb = s.vb;
|
||||||
|
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- живые состояния ----------
|
private _savePos(d: DevItem, x: number, y: number): void {
|
||||||
|
if (this._norm) {
|
||||||
|
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
|
||||||
|
this._layout = {
|
||||||
|
...this._layout,
|
||||||
|
[d.id]: { s: d.space, x: x / NORM_W, y: y / (NORM_W / aspect) },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||||
|
}
|
||||||
|
this._persistLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= живые состояния =================
|
||||||
|
|
||||||
private _stateClass(d: DevItem): string {
|
private _stateClass(d: DevItem): string {
|
||||||
if (!this._config?.live_states) return '';
|
if (!this._config?.live_states) return '';
|
||||||
const st = (eid?: string) => (eid ? this.hass.states[eid] : undefined);
|
const st = (eid?: string) => (eid ? this.hass.states[eid] : undefined);
|
||||||
@@ -392,7 +566,8 @@ class HouseplanCard extends LitElement {
|
|||||||
return this._tempFor(d.entities);
|
return this._tempFor(d.entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- взаимодействие ----------
|
// ================= взаимодействие =================
|
||||||
|
|
||||||
private _openMoreInfo(entityId?: string): void {
|
private _openMoreInfo(entityId?: string): void {
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
this._showToast('У устройства нет подходящей сущности');
|
this._showToast('У устройства нет подходящей сущности');
|
||||||
@@ -408,6 +583,10 @@ class HouseplanCard extends LitElement {
|
|||||||
this._selId = d.id;
|
this._selId = d.id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (d.virtual && !d.primary) {
|
||||||
|
this._showToast(d.name + (d.virtual.note ? ' — ' + d.virtual.note : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (d.members) {
|
if (d.members) {
|
||||||
this._menuDev = d;
|
this._menuDev = d;
|
||||||
this._menuXY = { x: ev.clientX, y: ev.clientY };
|
this._menuXY = { x: ev.clientX, y: ev.clientY };
|
||||||
@@ -417,14 +596,13 @@ class HouseplanCard extends LitElement {
|
|||||||
this._openMoreInfo(d.primary);
|
this._openMoreInfo(d.primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clickRoom(r: Room): void {
|
private _clickRoom(r: RoomCfg): void {
|
||||||
if (this._edit) return;
|
if (this._edit || !r.area) return;
|
||||||
navigate('/config/areas/area/' + r.area);
|
navigate('/config/areas/area/' + r.area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// drag
|
|
||||||
private _pointerDown(ev: PointerEvent, d: DevItem): void {
|
private _pointerDown(ev: PointerEvent, d: DevItem): void {
|
||||||
if (!this._edit) return;
|
if (!this._edit || d.virtual) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const p = this._pos(d);
|
const p = this._pos(d);
|
||||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||||
@@ -436,24 +614,22 @@ class HouseplanCard extends LitElement {
|
|||||||
if (!this._drag || this._drag.id !== d.id) return;
|
if (!this._drag || this._drag.id !== d.id) return;
|
||||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
const vb = FLOOR_VB[this._floor];
|
const vb = this._spaceModel().vb;
|
||||||
const rect = stage.getBoundingClientRect();
|
const rect = stage.getBoundingClientRect();
|
||||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * vb[2];
|
const dx = ((ev.clientX - this._drag.sx) / rect.width) * vb[2];
|
||||||
const dy = ((ev.clientY - this._drag.sy) / rect.height) * vb[3];
|
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;
|
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);
|
const m = Math.min(vb[2], vb[3]) * 0.008;
|
||||||
let ny = Math.round(this._drag.oy + dy);
|
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
|
||||||
nx = Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, nx));
|
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
|
||||||
ny = Math.max(vb[1] + 6, Math.min(vb[1] + vb[3] - 6, ny));
|
this._savePos(d, nx, ny);
|
||||||
this._layout = { ...this._layout, [d.id]: { x: nx, y: ny } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
|
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
|
||||||
if (!this._drag || this._drag.id !== d.id) return;
|
if (!this._drag || this._drag.id !== d.id) return;
|
||||||
const moved = this._drag.moved;
|
const moved = this._drag.moved;
|
||||||
this._drag = moved ? this._drag : null; // click-обработчик проверит moved
|
this._drag = moved ? this._drag : null;
|
||||||
if (moved) {
|
if (moved) {
|
||||||
this._persistLayout();
|
|
||||||
this._selId = d.id;
|
this._selId = d.id;
|
||||||
window.setTimeout(() => (this._drag = null), 0);
|
window.setTimeout(() => (this._drag = null), 0);
|
||||||
}
|
}
|
||||||
@@ -461,17 +637,13 @@ class HouseplanCard extends LitElement {
|
|||||||
|
|
||||||
private _applyXY(axis: 'x' | 'y', val: string): void {
|
private _applyXY(axis: 'x' | 'y', val: string): void {
|
||||||
if (!this._selId) return;
|
if (!this._selId) return;
|
||||||
const n = parseInt(val, 10);
|
const n = parseFloat(val);
|
||||||
if (isNaN(n)) return;
|
if (isNaN(n)) return;
|
||||||
const d = this._devices.find((x) => x.id === this._selId);
|
const d = this._devices.find((x) => x.id === this._selId);
|
||||||
if (!d) return;
|
if (!d || d.virtual) return;
|
||||||
const vb = FLOOR_VB[this._floor];
|
|
||||||
const p = { ...this._pos(d) };
|
const p = { ...this._pos(d) };
|
||||||
p[axis] = axis === 'x'
|
p[axis] = n;
|
||||||
? Math.max(vb[0] + 6, Math.min(vb[0] + vb[2] - 6, n))
|
this._savePos(d, p.x, p.y);
|
||||||
: 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 {
|
private _resetLayout(): void {
|
||||||
@@ -493,15 +665,93 @@ class HouseplanCard extends LitElement {
|
|||||||
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
|
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- рендер ----------
|
// ================= МИГРАЦИЯ legacy → сервер =================
|
||||||
|
|
||||||
|
private async _migrateToServer(): Promise<void> {
|
||||||
|
if (!this._serverStorage || this._norm || this._migrating) return;
|
||||||
|
if (!confirm('Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?')) return;
|
||||||
|
this._migrating = true;
|
||||||
|
try {
|
||||||
|
const spaces: any[] = [];
|
||||||
|
for (const fl of Object.keys(FLOOR_VB)) {
|
||||||
|
const vb = FLOOR_VB[fl];
|
||||||
|
const rect = FLOOR_BG_RECT[fl] || vb; // yard: нормируем относительно viewBox
|
||||||
|
const [rx, ry, rw, rh] = rect;
|
||||||
|
let planUrl: string | null = null;
|
||||||
|
let aspect = rw / rh;
|
||||||
|
const bg = FLOOR_BG[fl];
|
||||||
|
if (bg && FLOOR_BG_RECT[fl]) {
|
||||||
|
const nat = LEGACY_PLAN_SIZE[fl];
|
||||||
|
if (nat) aspect = nat[0] / nat[1];
|
||||||
|
const b64 = bg.split(',')[1];
|
||||||
|
const resp = await this.hass.callWS({
|
||||||
|
type: 'houseplan/plan/set',
|
||||||
|
space_id: fl,
|
||||||
|
ext: 'svg',
|
||||||
|
data: b64,
|
||||||
|
});
|
||||||
|
planUrl = resp.url;
|
||||||
|
}
|
||||||
|
const rooms = ROOMS.filter((r) => r.floor === fl).map((r, i) => ({
|
||||||
|
id: fl + '_r' + i,
|
||||||
|
name: r.name,
|
||||||
|
area: r.area || null,
|
||||||
|
x: (r.x - rx) / rw,
|
||||||
|
y: (r.y - ry) / rh,
|
||||||
|
w: r.w / rw,
|
||||||
|
h: r.h / rh,
|
||||||
|
}));
|
||||||
|
spaces.push({
|
||||||
|
id: fl,
|
||||||
|
title: FLOOR_TITLES[fl] || fl,
|
||||||
|
plan_url: planUrl,
|
||||||
|
aspect,
|
||||||
|
view_box: [(vb[0] - rx) / rw, (vb[1] - ry) / rh, vb[2] / rw, vb[3] / rh],
|
||||||
|
rooms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
spaces,
|
||||||
|
device_overrides: {},
|
||||||
|
virtual_devices: [],
|
||||||
|
settings: { exclude_integrations: [...EXCLUDED_DOMAINS], group_lights: true },
|
||||||
|
};
|
||||||
|
// раскладка: канвас → нормированные координаты пространства
|
||||||
|
const layout: Record<string, any> = {};
|
||||||
|
for (const d of this._devices) {
|
||||||
|
if (d.virtual) continue;
|
||||||
|
const saved = this._layout[d.id] || this._defPos[d.id];
|
||||||
|
if (!saved) continue;
|
||||||
|
const rect = FLOOR_BG_RECT[d.space] || FLOOR_VB[d.space];
|
||||||
|
layout[d.id] = {
|
||||||
|
s: d.space,
|
||||||
|
x: (saved.x - rect[0]) / rect[2],
|
||||||
|
y: (saved.y - rect[1]) / rect[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.hass.callWS({ type: 'houseplan/config/set', config });
|
||||||
|
await this.hass.callWS({ type: 'houseplan/layout/set', layout });
|
||||||
|
this._serverCfg = config as any;
|
||||||
|
this._layout = layout;
|
||||||
|
this._regSignature = '';
|
||||||
|
this._maybeRebuildDevices();
|
||||||
|
this._showToast('Конфигурация перенесена на сервер — карта работает от server config');
|
||||||
|
} catch (e: any) {
|
||||||
|
this._showToast('Ошибка миграции: ' + (e?.message || e));
|
||||||
|
} finally {
|
||||||
|
this._migrating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= рендер =================
|
||||||
|
|
||||||
protected render(): TemplateResult | typeof nothing {
|
protected render(): TemplateResult | typeof nothing {
|
||||||
if (!this._config || !this.hass) return nothing;
|
if (!this._config || !this.hass) return nothing;
|
||||||
const vb = FLOOR_VB[this._floor];
|
const model = this._model;
|
||||||
const bg = FLOOR_BG[this._floor];
|
if (!model.length) return html`<ha-card><div class="empty">Нет настроенных пространств</div></ha-card>`;
|
||||||
const bgRect = FLOOR_BG_RECT[this._floor];
|
const space = this._spaceModel();
|
||||||
const rooms = this._rooms.filter((r) => r.floor === this._floor && r.area);
|
const vb = space.vb;
|
||||||
const devs = this._devices.filter((d) => d.floor === this._floor);
|
const devs = this._devices.filter((d) => d.space === space.id);
|
||||||
// размер иконки в % от ширины видимой области плана (legacy px-значения > 8 игнорируются)
|
|
||||||
const cfgSize = this._config.icon_size ?? 2.5;
|
const cfgSize = this._config.icon_size ?? 2.5;
|
||||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||||
|
|
||||||
@@ -513,15 +763,15 @@ class HouseplanCard extends LitElement {
|
|||||||
${this._config.title || 'План дома'}
|
${this._config.title || 'План дома'}
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
${Object.keys(FLOOR_VB).map(
|
${model.map(
|
||||||
(f) => html`<button
|
(s) => html`<button
|
||||||
class="tab ${this._floor === f ? 'active' : ''}"
|
class="tab ${this._space === s.id ? 'active' : ''}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this._floor = f;
|
this._space = s.id;
|
||||||
this._selId = null;
|
this._selId = null;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${FLOOR_TITLES[f] || f}
|
${s.title}
|
||||||
</button>`,
|
</button>`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -537,20 +787,20 @@ class HouseplanCard extends LitElement {
|
|||||||
|
|
||||||
<div class="stage ${this._edit ? 'edit' : ''}" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
<div class="stage ${this._edit ? 'edit' : ''}" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||||
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
|
<svg viewBox="${vb.join(' ')}" preserveAspectRatio="xMidYMid meet">
|
||||||
${bg && bgRect
|
${space.bg
|
||||||
? svg`<image href="${bg}" x="${bgRect[0]}" y="${bgRect[1]}" width="${bgRect[2]}" height="${bgRect[3]}" preserveAspectRatio="none" />`
|
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
${rooms.map(
|
${space.rooms.filter((r) => r.area).map(
|
||||||
(r) => svg`<rect
|
(r) => svg`<rect
|
||||||
class="room ${bg ? 'overlay' : 'yard'}"
|
class="room ${space.bg ? 'overlay' : 'yard'}"
|
||||||
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8"
|
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w, r.h) * 0.03}"
|
||||||
@click=${() => this._clickRoom(r)}
|
@click=${() => this._clickRoom(r)}
|
||||||
@mousemove=${(e: MouseEvent) =>
|
@mousemove=${(e: MouseEvent) =>
|
||||||
this._showTip(e, r.name, 'комната — открыть зону',
|
this._showTip(e, r.name, 'комната — открыть зону',
|
||||||
this._config?.show_signal ? this._roomLqi(r.area) : null)}
|
this._config?.show_signal ? this._roomLqi(r.area) : null)}
|
||||||
@mouseleave=${() => (this._tip = null)}
|
@mouseleave=${() => (this._tip = null)}
|
||||||
></rect>
|
></rect>
|
||||||
${!bg ? svg`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + 26}">${r.name}</text>` : nothing}`,
|
${!space.bg ? svg`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + Math.min(r.w, r.h) * 0.1}">${r.name}</text>` : nothing}`,
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
<div class="devlayer" style="--icon-size:${iconPct}cqw">
|
||||||
@@ -580,9 +830,9 @@ class HouseplanCard extends LitElement {
|
|||||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||||
const cls = this._stateClass(d);
|
const cls = this._stateClass(d);
|
||||||
const temp = this._liveTemp(d);
|
const temp = this._liveTemp(d);
|
||||||
const lqi = this._config?.show_signal ? this._lqiFor(d.entities) : null;
|
const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null;
|
||||||
return html`<div
|
return html`<div
|
||||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''}"
|
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
|
||||||
style="left:${left}%;top:${top}%"
|
style="left:${left}%;top:${top}%"
|
||||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||||
@mousemove=${(e: MouseEvent) =>
|
@mousemove=${(e: MouseEvent) =>
|
||||||
@@ -612,7 +862,19 @@ class HouseplanCard extends LitElement {
|
|||||||
@change=${(e: Event) => this._applyXY('y', (e.target as HTMLInputElement).value)} />`
|
@change=${(e: Event) => this._applyXY('y', (e.target as HTMLInputElement).value)} />`
|
||||||
: nothing}
|
: nothing}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="hint">${this._serverStorage ? 'сохранение: сервер (для всех)' : 'сохранение: этот браузер'}</span>
|
<span class="hint">
|
||||||
|
${this._serverStorage
|
||||||
|
? this._norm
|
||||||
|
? 'конфиг и раскладка: сервер'
|
||||||
|
: 'раскладка: сервер · конфиг: встроенный'
|
||||||
|
: 'сохранение: этот браузер'}
|
||||||
|
</span>
|
||||||
|
${this._serverStorage && !this._norm
|
||||||
|
? html`<button class="btn" ?disabled=${this._migrating} @click=${this._migrateToServer}
|
||||||
|
title="Перенести планы, комнаты и раскладку в хранилище HA">
|
||||||
|
<ha-icon icon="mdi:cloud-upload"></ha-icon>${this._migrating ? '…' : 'На сервер'}
|
||||||
|
</button>`
|
||||||
|
: nothing}
|
||||||
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
|
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
|
||||||
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
||||||
</button>
|
</button>
|
||||||
@@ -658,10 +920,13 @@ class HouseplanCard extends LitElement {
|
|||||||
ha-card {
|
ha-card {
|
||||||
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
|
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
|
||||||
}
|
}
|
||||||
.head {
|
.empty {
|
||||||
border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0;
|
padding: 32px;
|
||||||
|
color: var(--hp-muted);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.head {
|
.head {
|
||||||
|
border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -729,6 +994,8 @@ class HouseplanCard extends LitElement {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.15s;
|
transition: 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.btn ha-icon {
|
.btn ha-icon {
|
||||||
--mdc-icon-size: 17px;
|
--mdc-icon-size: 17px;
|
||||||
@@ -744,10 +1011,14 @@ class HouseplanCard extends LitElement {
|
|||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
.btn[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
container-type: inline-size; /* cqw = % ширины плана для размеров иконок */
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
.stage svg {
|
.stage svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -807,7 +1078,7 @@ class HouseplanCard extends LitElement {
|
|||||||
color: var(--hp-txt);
|
color: var(--hp-txt);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: transform 0.08s, background 0.15s, border-color 0.15s, opacity 0.2s;
|
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -833,6 +1104,9 @@ class HouseplanCard extends LitElement {
|
|||||||
.dev.unavail {
|
.dev.unavail {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
.dev.virtual {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
.dev.sel {
|
.dev.sel {
|
||||||
border-color: #ffc14d;
|
border-color: #ffc14d;
|
||||||
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
|
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
|
||||||
@@ -988,4 +1262,14 @@ if (!customElements.get('houseplan-card')) {
|
|||||||
customElements.define('houseplan-card', HouseplanCard);
|
customElements.define('houseplan-card', HouseplanCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
(wi
|
(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', '');
|
||||||
|
|||||||
Reference in New Issue
Block a user