mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-30 08:46:03 +00:00
audit+refactor v1.7.0: drop hardcoded dacha bundle (293KB->83KB) + onboarding empty-state; remove migration & dead device_overrides/virtual_devices; extract src/logic.ts + validation.py (pure, unit-tested: 9 front + 10 back); fix latent bugs unmasked by truncated rules.ts (DOMAIN_PRIORITY, poly _defaultPositions); harden path sanitizers (leading-dot traversal); tsc strict in build+CI; sync versions
This commit is contained in:
@@ -19,12 +19,27 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
build:
|
||||
frontend:
|
||||
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: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install pytest voluptuous
|
||||
- name: Backend unit tests
|
||||
run: python -m pytest tests_backend/ -q
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
node_modules/
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
Binary file not shown.
Executable → Regular
Binary file not shown.
Binary file not shown.
@@ -10,11 +10,10 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.6.2"
|
||||
VERSION = "1.7.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"device_overrides": {},
|
||||
"virtual_devices": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"codeowners": ["@justbusiness"],
|
||||
"codeowners": [
|
||||
"@justbusiness"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "frontend", "websocket_api"],
|
||||
"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.4.0"
|
||||
}
|
||||
"version": "1.7.0"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Чистая валидация и санитайзеры House Plan — без зависимостей от Home Assistant.
|
||||
|
||||
Вынесено отдельно, чтобы покрывать юнит-тестами (нужен только voluptuous).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
# ---------- лимиты и наборы расширений ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
# ---------- санитайзеры ----------
|
||||
|
||||
|
||||
def sanitize_marker_id(value: str) -> str:
|
||||
"""Безопасный идентификатор маркера для имени папки.
|
||||
|
||||
Убирает разделители путей и ведущие точки, чтобы исключить обход каталогов
|
||||
(напр. '..', '../x'); пустой/из одних точек результат → 'misc'.
|
||||
"""
|
||||
cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64]
|
||||
return cleaned or "misc"
|
||||
|
||||
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Отбросить путь и ведущие точки, оставить безопасное имя файла."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
"""Расширение файла в нижнем регистре ('' если нет)."""
|
||||
raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return raw.rsplit(".", 1)[-1].lower() if "." in raw else ""
|
||||
|
||||
|
||||
def valid_space_id(value: str) -> bool:
|
||||
return bool(SPACE_ID_RE.match(value))
|
||||
|
||||
|
||||
# ---------- voluptuous-схемы ----------
|
||||
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})
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
|
||||
return room
|
||||
raise vol.Invalid("room: нужен poly или x/y/w/h")
|
||||
|
||||
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): str,
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # неизвестные ключи (легаси) не ломают загрузку
|
||||
)
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -16,105 +15,11 @@ from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN,
|
||||
FILES_DIR, FILES_URL, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
# прямоугольная комната (legacy) …
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
# … или полигон (редактор разметки)
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
lambda r: r if ("poly" in r or all(k in r for k in ("x", "y", "w", "h")))
|
||||
else (_ for _ in ()).throw(vol.Invalid("room: нужен poly или x/y/w/h")),
|
||||
)
|
||||
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],
|
||||
# сегменты-«стены» (разметочный каркас): [x1,y1,x2,y2], нормированные
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
},
|
||||
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,
|
||||
)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): str,
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, FILE_EXTENSIONS, LAYOUT_SCHEMA, MAX_FILE_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, file_ext, sanitize_filename, sanitize_marker_id, valid_space_id,
|
||||
)
|
||||
|
||||
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("markers", default=list): [MARKER_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
|
||||
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
@callback
|
||||
@@ -251,7 +156,7 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], "unauthorized", "Загрузка планов разрешена только администраторам")
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not SPACE_ID_RE.match(space_id):
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: только [a-z0-9_-], до 64 символов")
|
||||
return
|
||||
try:
|
||||
@@ -296,13 +201,13 @@ async def ws_file_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Загрузка файлов разрешена только администраторам")
|
||||
return
|
||||
marker_id = _SAFE_NAME_RE.sub("_", msg["marker_id"])[:64] or "misc"
|
||||
marker_id = sanitize_marker_id(msg["marker_id"])
|
||||
raw_name = msg["filename"].rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
ext = raw_name.rsplit(".", 1)[-1].lower() if "." in raw_name else ""
|
||||
ext = file_ext(raw_name)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
connection.send_error(msg["id"], "bad_ext", f"Разрешены: {', '.join(sorted(FILE_EXTENSIONS))}")
|
||||
return
|
||||
safe_name = _SAFE_NAME_RE.sub("_", raw_name)[:120]
|
||||
safe_name = sanitize_filename(raw_name)
|
||||
try:
|
||||
blob = base64.b64decode(msg["data"], validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
|
||||
Vendored
+179
-152
File diff suppressed because one or more lines are too long
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.0 — 2026-07-04 (аудит + рефакторинг + тесты)
|
||||
- Удалён вшитый в бандл дом-образец (дача, ~245 КБ base64 планов + ROOMS/FLOOR_*): бандл
|
||||
293 КБ → 83 КБ. Свежая установка показывает онбординг «Добавить пространство», а не чужой дом.
|
||||
- Удалён код миграции legacy→сервер (дача уже на server config) и мёртвые пути
|
||||
device_overrides/virtual_devices (заменены markers[]).
|
||||
- Чистая логика вынесена в src/logic.ts (lqiColor, snapToGrid, segKey, pointInPolygon,
|
||||
markerIdForBinding, averageLqi) — покрыта юнит-тестами (node:test, 9 тестов + iconFor).
|
||||
- Бэкенд: валидация/санитайзеры вынесены в validation.py (без HA-импортов) — pytest, 10 тестов.
|
||||
- БЕЗОПАСНОСТЬ: тест выявил, что санитайзер имён файлов/маркеров сохранял ведущие точки
|
||||
(риск обхода каталогов через «..»); добавлено срезание ведущих точек, пустое → misc/file.
|
||||
- Исправлены реальные баги, ранее замаскированные обрезанным rules.ts: DOMAIN_PRIORITY был
|
||||
усечён (ломало выбор more-info-сущности); _defaultPositions падал на полигон-комнатах
|
||||
(теперь bbox). tsc --noEmit добавлен в сборку и CI — строгая типизация проходит.
|
||||
- Убраны дубли-мусор src/data/{editor,rules}.ts; версии синхронизированы (manifest застрял на 1.4.0).
|
||||
- CI: добавлены typecheck, юнит-тесты фронта и бэка.
|
||||
|
||||
## v1.6.2 — 2026-07-04
|
||||
- Поле «Комната» в диалоге устройства теперь для ВСЕХ значков (не только виртуальных):
|
||||
для привязанных — «переопределить размещение» (по умолчанию по зоне устройства); смена
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
ВСЕХ дашбордов HA (карточка грузится как extra_module на каждой странице!).
|
||||
3. `.git` на mount не создаётся («Operation not permitted» на dot-каталогах) — поэтому bundle.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Фронт: `npm test` — компилит src/logic.ts+rules.ts (tsconfig.test.json) и гоняет node:test
|
||||
(test/*.test.mjs). Строгая типизация: `npm run typecheck` (tsc --noEmit, входит в `npm run build`).
|
||||
- Бэк: `python -m pytest tests_backend/` — чистая валидация custom_components/houseplan/validation.py
|
||||
(грузится по пути, без импорта HA-пакета).
|
||||
- ВАЖНО (аудит-урок): rollup-плагин typescript выдаёт синтаксическую ошибку как WARNING и всё равно
|
||||
собирает бандл — обрезанный файл может «пройти». Поэтому в сборке первым идёт `tsc --noEmit`,
|
||||
который падает на таких ошибках. Всегда собирать `npm run build`, не голый `rollup -c`.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
|
||||
+6
-4
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c --watch"
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
@@ -19,4 +21,4 @@
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,68 +0,0 @@
|
||||
/** Редактор конфигурации карточки (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: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
{ name: 'show_temperature', selector: { boolean: {} } },
|
||||
{ name: 'live_states', selector: { boolean: {} } },
|
||||
{ name: 'show_signal', selector: { boolean: {} } },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
title: 'Заголовок',
|
||||
default_floor: 'Этаж по умолчанию',
|
||||
icon_size: 'Размер иконок, % ширины плана',
|
||||
show_temperature: 'Показывать температуру',
|
||||
live_states: 'Живые состояния (вкл/выкл, открыто…)',
|
||||
show_signal: 'Показывать сигнал zigbee (LQI)',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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": 518,
|
||||
"y": 226,
|
||||
"w": 420,
|
||||
"h": 292
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевой с/у",
|
||||
"area": "gostevoi_s_u",
|
||||
"x": 615,
|
||||
"y": 524,
|
||||
"w": 162,
|
||||
"h": 156
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Котельная",
|
||||
"area": "kotelnaia",
|
||||
"x": 614,
|
||||
"y": 686,
|
||||
"w": 163,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Кладовка уличная",
|
||||
"area": "kladovka",
|
||||
"x": 518,
|
||||
"y": 686,
|
||||
"w": 82,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Прихожая",
|
||||
"area": "prikhozhaia",
|
||||
"x": 784,
|
||||
"y": 600,
|
||||
"w": 152,
|
||||
"h": 255
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевая спальня",
|
||||
"area": "gostevaia_komnata",
|
||||
"x": 949,
|
||||
"y": 611,
|
||||
"w": 201,
|
||||
"h": 245
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Майя",
|
||||
"area": "detskaia_maiia",
|
||||
"x": 389,
|
||||
"y": 58,
|
||||
"w": 424,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Элина",
|
||||
"area": "detskaia_elina",
|
||||
"x": 830,
|
||||
"y": 58,
|
||||
"w": 426,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детский с/у",
|
||||
"area": "detskii_s_u",
|
||||
"x": 389,
|
||||
"y": 360,
|
||||
"w": 264,
|
||||
"h": 168
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер с/у",
|
||||
"area": "master_s_u",
|
||||
"x": 389,
|
||||
"y": 545,
|
||||
"w": 264,
|
||||
"h": 155
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер-спальня",
|
||||
"area": "spalnia",
|
||||
"x": 657,
|
||||
"y": 607,
|
||||
"w": 320,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Кабинет",
|
||||
"area": "kabinet",
|
||||
"x": 984,
|
||||
"y": 607,
|
||||
"w": 274,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"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": "Двор / Участок"
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Правила прототипа — перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.2–4.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|sn609|sn9161', 'mdi:lock'],
|
||||
['ворота|garage', 'mdi:garage-variant'],
|
||||
['калитк|door|открыт', 'mdi:door'],
|
||||
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'],
|
||||
['вводный автомат|breaker|wifimcbn', 'mdi:electric-switch'],
|
||||
['myheat|котёл|котел|boiler|отоплен', 'mdi:water-boiler'],
|
||||
['холодильник|fridge', 'mdi:fridge'],
|
||||
['стиральн|washer', 'mdi:washing-machine'],
|
||||
['сушилк|dryer', 'mdi:tumble-dryer'],
|
||||
['пылесос|vacuum|dreame', 'mdi:robot-vacuum'],
|
||||
['soundbar|колонк|станц', 'mdi:soundbar'],
|
||||
['tv|телевизор|hyundaitv|mitv', 'mdi:television'],
|
||||
['keenetic|роутер|router', 'mdi:router-wireless'],
|
||||
['ибп|ups|kirpich', 'mdi:battery-charging-high'],
|
||||
['slzb|координат|zigbee', 'mdi:zigbee'],
|
||||
];
|
||||
|
||||
/** Подбор 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',
|
||||
+91
-186
@@ -7,21 +7,16 @@
|
||||
* Раскладка иконок хранится на сервере (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { ROOMS, FLOOR_VB, FLOOR_TITLES, Room } from './data/house';
|
||||
import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
||||
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
} from './logic';
|
||||
import './editor';
|
||||
|
||||
const CARD_VERSION = '1.6.2';
|
||||
const CARD_VERSION = '1.7.0';
|
||||
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;
|
||||
@@ -44,17 +39,6 @@ interface SpaceModel {
|
||||
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 PdfRef {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -77,8 +61,6 @@ interface Marker {
|
||||
|
||||
interface ServerConfig {
|
||||
spaces: any[];
|
||||
device_overrides: Record<string, { hidden?: boolean; icon?: string | null; name?: string | null }>;
|
||||
virtual_devices: VirtualDevice[];
|
||||
markers: Marker[];
|
||||
settings: { exclude_integrations?: string[]; group_lights?: boolean };
|
||||
}
|
||||
@@ -93,7 +75,7 @@ interface DevItem {
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
temp?: number | null;
|
||||
virtual?: VirtualDevice;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // связанный маркер конфига (метаданные, оверрайды)
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
bindingRef?: string; // device_id / entity_id
|
||||
@@ -110,15 +92,8 @@ interface CardConfig {
|
||||
show_temperature?: boolean;
|
||||
live_states?: boolean;
|
||||
show_signal?: boolean;
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
/** Цвет LQI: ≤40 — красный, ≥180 — зелёный, между — градиент. */
|
||||
const lqiColor = (lqi: number): string => {
|
||||
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
};
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||
ev.detail = detail ?? {};
|
||||
@@ -157,7 +132,6 @@ class HouseplanCard extends LitElement {
|
||||
private _selId: string | null = null;
|
||||
private _toast = '';
|
||||
private _toastTimer?: number;
|
||||
private _migrating = false;
|
||||
|
||||
// --- редактор разметки комнат ---
|
||||
private _markup = false;
|
||||
@@ -205,7 +179,6 @@ class HouseplanCard extends LitElement {
|
||||
_selId: { state: true },
|
||||
_toast: { state: true },
|
||||
_serverCfg: { state: true },
|
||||
_migrating: { state: true },
|
||||
_markup: { state: true },
|
||||
_tool: { state: true },
|
||||
_path: { state: true },
|
||||
@@ -294,49 +267,34 @@ class HouseplanCard extends LitElement {
|
||||
return 12;
|
||||
}
|
||||
|
||||
// ================= РЕЗОЛВ МОДЕЛИ (сервер или legacy) =================
|
||||
// ================= РЕЗОЛВ МОДЕЛИ (серверная конфигурация) =================
|
||||
|
||||
/** Есть ли серверная конфигурация с пространствами (иначе — онбординг). */
|
||||
private get _norm(): boolean {
|
||||
return !!(this._serverCfg && this._serverCfg.spaces.length);
|
||||
}
|
||||
|
||||
/** Пространства в рендер-единицах: сервер → NORM_W×NORM_W/aspect; legacy → холст 1489×1053. */
|
||||
/** Пространства в рендер-единицах (NORM_W × NORM_W/aspect). */
|
||||
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 != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
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),
|
||||
};
|
||||
if (!this._serverCfg) return [];
|
||||
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 != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
}
|
||||
// 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 })),
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -596,7 +554,6 @@ class HouseplanCard extends LitElement {
|
||||
private _buildDevices(): DevItem[] {
|
||||
const h = this.hass;
|
||||
const areaMap = this._areaToSpace;
|
||||
const overrides = this._serverCfg?.device_overrides || {};
|
||||
const groupLights = this._settings.group_lights !== false;
|
||||
const groupedAreas = this._groupedLightAreas();
|
||||
const excluded = this._excluded;
|
||||
@@ -611,9 +568,8 @@ class HouseplanCard extends LitElement {
|
||||
if (!area || !areaMap[area]) continue;
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже
|
||||
const ov = overrides[dev.id] || {};
|
||||
const marker = this._markerFor('device', dev.id);
|
||||
if ((marker && marker.hidden) || ov.hidden) continue;
|
||||
if (marker && marker.hidden) continue;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = this._domainOfDevice(dev, entIds);
|
||||
if (excluded.has(dom)) continue;
|
||||
@@ -621,13 +577,12 @@ class HouseplanCard extends LitElement {
|
||||
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 = (ov.name || dev.name_by_user || dev.name || 'без имени').trim();
|
||||
const name = (dev.name_by_user || dev.name || 'без имени').trim();
|
||||
const key = name + '|' + area;
|
||||
if (seen[key]) continue;
|
||||
seen[key] = 1;
|
||||
let icon = iconFor(name, dev.model);
|
||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||
if (ov.icon) icon = ov.icon;
|
||||
if (groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
|
||||
const item: DevItem = {
|
||||
id: dev.id,
|
||||
@@ -723,7 +678,7 @@ class HouseplanCard extends LitElement {
|
||||
icon: m.icon || 'mdi:map-marker',
|
||||
entities: [],
|
||||
bindingKind: 'virtual',
|
||||
virtual: { id: m.id, space, name: m.name || '', icon: m.icon || 'mdi:map-marker', x: 0, y: 0 },
|
||||
virtual: true,
|
||||
};
|
||||
this._applyMarker(item, m);
|
||||
rest.push(item);
|
||||
@@ -734,6 +689,18 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
// ================= позиции =================
|
||||
|
||||
/** Ограничивающий прямоугольник комнаты (rect или полигон) в рендер-единицах. */
|
||||
private _roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
const xs = r.poly.map((p) => p[0]);
|
||||
const ys = r.poly.map((p) => p[1]);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
|
||||
}
|
||||
|
||||
private _defaultPositions(): Record<string, { x: number; y: number }> {
|
||||
const map: Record<string, { x: number; y: number }> = {};
|
||||
for (const s of this._model) {
|
||||
@@ -741,9 +708,10 @@ class HouseplanCard extends LitElement {
|
||||
if (!r.area) continue;
|
||||
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id);
|
||||
if (!ds.length) continue;
|
||||
const pad = Math.min(r.w, r.h) * 0.12;
|
||||
const iw = r.w - pad * 2;
|
||||
const ih = r.h - pad * 2;
|
||||
const b = this._roomBounds(r);
|
||||
const pad = Math.min(b.w, b.h) * 0.12;
|
||||
const iw = b.w - pad * 2;
|
||||
const ih = b.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;
|
||||
@@ -751,7 +719,7 @@ class HouseplanCard extends LitElement {
|
||||
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) };
|
||||
map[d.id] = { x: b.x + pad + cw * (c + 0.5), y: b.y + pad + ch * (rw + 0.5) };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -956,16 +924,15 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _snap(p: number[]): number[] {
|
||||
const g = this._gridPitch;
|
||||
return [Math.round(p[0] / g) * g, Math.round(p[1] / g) * g];
|
||||
return [snapToGrid(p[0], g), snapToGrid(p[1], g)];
|
||||
}
|
||||
|
||||
private _samePt(a: number[], b: number[]): boolean {
|
||||
return Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
|
||||
return samePoint(a, b);
|
||||
}
|
||||
|
||||
private _segKey(a: number[], b: number[]): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
return segKeyOf(a, b);
|
||||
}
|
||||
|
||||
private _saveConfig = debounce(() => {
|
||||
@@ -1014,16 +981,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||||
if (r.poly) {
|
||||
let inside = false;
|
||||
const poly = r.poly;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i];
|
||||
const [xj, yj] = poly[j];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
if (r.poly) return pointInPolygon(p, r.poly);
|
||||
return (
|
||||
r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h!
|
||||
);
|
||||
@@ -1285,13 +1243,8 @@ class HouseplanCard extends LitElement {
|
||||
const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', ''];
|
||||
let space: string | null = roomSp || null;
|
||||
let area: string | null = roomAr || null;
|
||||
if (dlg.binding === 'virtual') {
|
||||
if (!space) space = this._space;
|
||||
id = dlg.devId && dlg.devId.startsWith('v_') ? dlg.devId : 'v_' + Date.now().toString(36);
|
||||
} else {
|
||||
const [kind, ref] = dlg.binding.split(':');
|
||||
id = kind === 'device' ? ref : 'lg_' + ref;
|
||||
}
|
||||
if (dlg.binding === 'virtual' && !space) space = this._space;
|
||||
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
|
||||
const oldId = dlg.devId;
|
||||
const marker: Marker = {
|
||||
id,
|
||||
@@ -1379,8 +1332,8 @@ class HouseplanCard extends LitElement {
|
||||
// ================= УПРАВЛЕНИЕ ПРОСТРАНСТВАМИ =================
|
||||
|
||||
private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
|
||||
if (!this._norm) {
|
||||
this._showToast('Управление пространствами доступно после переноса конфига на сервер');
|
||||
if (!this._serverStorage || !this._serverCfg) {
|
||||
this._showToast('Интеграция House Plan не установлена — управление недоступно');
|
||||
return;
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
@@ -1488,90 +1441,31 @@ class HouseplanCard extends LitElement {
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
}
|
||||
|
||||
// ================= МИГРАЦИЯ 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 {
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const model = this._model;
|
||||
if (!model.length) return html`<ha-card><div class="empty">Нет настроенных пространств</div></ha-card>`;
|
||||
if (!model.length) {
|
||||
return html`<ha-card>
|
||||
<div class="head">
|
||||
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || 'План дома'}</div>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<ha-icon icon="mdi:floor-plan" class="big"></ha-icon>
|
||||
<p>Пространств пока нет.</p>
|
||||
${this._serverStorage
|
||||
? html`<p class="muted">Добавьте первое пространство и загрузите план этажа.</p>
|
||||
<button class="btn on" @click=${() => this._openSpaceDialog('create')}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>Добавить пространство
|
||||
</button>`
|
||||
: html`<p class="muted">Установите интеграцию House Plan и добавьте запись в «Устройства и службы».</p>`}
|
||||
</div>
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
|
||||
</ha-card>`;
|
||||
}
|
||||
const space = this._spaceModel();
|
||||
const vb = space.vb;
|
||||
const devs = this._devices.filter((d) => d.space === space.id);
|
||||
@@ -2021,12 +1915,6 @@ class HouseplanCard extends LitElement {
|
||||
: 'раскладка: сервер · конфиг: встроенный'
|
||||
: 'сохранение: этот браузер'}
|
||||
</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="Сбросить всё">
|
||||
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
||||
</button>
|
||||
@@ -2047,9 +1935,26 @@ class HouseplanCard extends LitElement {
|
||||
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
|
||||
}
|
||||
.empty {
|
||||
padding: 32px;
|
||||
color: var(--hp-muted);
|
||||
padding: 40px 24px;
|
||||
color: var(--hp-txt);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .big {
|
||||
--mdc-icon-size: 56px;
|
||||
color: var(--hp-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.empty .muted {
|
||||
color: var(--hp-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.empty .btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.hdr {
|
||||
position: sticky;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Чистые функции без зависимостей от Lit/DOM — легко покрываются юнит-тестами.
|
||||
*/
|
||||
|
||||
/** Цвет LQI zigbee: ≤40 — красный, ≥180 — зелёный, между — hsl-градиент. */
|
||||
export function lqiColor(lqi: number): string {
|
||||
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
}
|
||||
|
||||
/** Привязать координату к ближайшему узлу сетки с шагом pitch. */
|
||||
export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
}
|
||||
|
||||
/** Канонический ключ отрезка (независим от направления). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
}
|
||||
|
||||
/** Совпадение точек с допуском. */
|
||||
export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
|
||||
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
|
||||
}
|
||||
|
||||
/** Точка внутри полигона (ray casting). */
|
||||
export function pointInPolygon(p: number[], poly: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i];
|
||||
const [xj, yj] = poly[j];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* id маркера по привязке: device → device_id, entity → 'lg_'+entity_id,
|
||||
* virtual → переданный existing (если это уже v_-маркер) либо новый через newId().
|
||||
*/
|
||||
export function markerIdForBinding(
|
||||
binding: string,
|
||||
existingId: string | undefined,
|
||||
newId: () => string,
|
||||
): string {
|
||||
const [kind, ref] = binding.split(':');
|
||||
if (kind === 'device') return ref;
|
||||
if (kind === 'entity') return 'lg_' + ref;
|
||||
return existingId && existingId.startsWith('v_') ? existingId : newId();
|
||||
}
|
||||
|
||||
/** Средний LQI по набору значений (или null). */
|
||||
export function averageLqi(values: number[]): number | null {
|
||||
if (!values.length) return null;
|
||||
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
|
||||
}
|
||||
+2
-1
@@ -60,4 +60,5 @@ 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',
|
||||
'number', 'select', 'update',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
} from '../test-build/logic.js';
|
||||
import { iconFor } from '../test-build/rules.js';
|
||||
|
||||
test('lqiColor: границы и середина', () => {
|
||||
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
|
||||
assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)');
|
||||
assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)');
|
||||
assert.equal(lqiColor(0), 'hsl(0, 85%, 55%)');
|
||||
assert.equal(lqiColor(255), 'hsl(120, 85%, 55%)');
|
||||
});
|
||||
|
||||
test('snapToGrid', () => {
|
||||
assert.equal(snapToGrid(0, 10), 0);
|
||||
assert.equal(snapToGrid(14, 10), 10);
|
||||
assert.equal(snapToGrid(16, 10), 20);
|
||||
});
|
||||
|
||||
test('segKey: направление не влияет', () => {
|
||||
assert.equal(segKey([0, 0], [10, 5]), segKey([10, 5], [0, 0]));
|
||||
assert.notEqual(segKey([0, 0], [10, 5]), segKey([0, 0], [10, 6]));
|
||||
});
|
||||
|
||||
test('samePoint с допуском', () => {
|
||||
assert.ok(samePoint([1, 1], [1.0005, 0.9995]));
|
||||
assert.ok(!samePoint([1, 1], [1.5, 1]));
|
||||
});
|
||||
|
||||
test('pointInPolygon: квадрат', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
assert.ok(pointInPolygon([5, 5], sq));
|
||||
assert.ok(!pointInPolygon([15, 5], sq));
|
||||
assert.ok(!pointInPolygon([-1, -1], sq));
|
||||
});
|
||||
|
||||
test('pointInPolygon: L-образный полигон', () => {
|
||||
const L = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]];
|
||||
assert.ok(pointInPolygon([1, 5], L));
|
||||
assert.ok(pointInPolygon([5, 1], L));
|
||||
assert.ok(!pointInPolygon([5, 5], L));
|
||||
});
|
||||
|
||||
test('markerIdForBinding', () => {
|
||||
let n = 0; const nid = () => 'v_new' + n++;
|
||||
assert.equal(markerIdForBinding('device:abc', undefined, nid), 'abc');
|
||||
assert.equal(markerIdForBinding('entity:light.x', undefined, nid), 'lg_light.x');
|
||||
assert.equal(markerIdForBinding('virtual', 'v_existing', nid), 'v_existing');
|
||||
assert.equal(markerIdForBinding('virtual', undefined, nid), 'v_new0');
|
||||
assert.equal(markerIdForBinding('virtual', 'abc', nid), 'v_new1');
|
||||
});
|
||||
|
||||
test('averageLqi', () => {
|
||||
assert.equal(averageLqi([]), null);
|
||||
assert.equal(averageLqi([100]), 100);
|
||||
assert.equal(averageLqi([100, 200]), 150);
|
||||
assert.equal(averageLqi([1, 2, 2]), 2);
|
||||
});
|
||||
|
||||
test('iconFor: ключевые правила', () => {
|
||||
assert.equal(iconFor('Датчик протечки кухня', 'HOBEIAN'), 'mdi:water-alert');
|
||||
assert.equal(iconFor('Замок Терраса', 'TTLock'), 'mdi:lock');
|
||||
assert.equal(iconFor('Настенная лампа 1', 'Yandex Bulb'), 'mdi:lightbulb');
|
||||
assert.equal(iconFor('Ворота', 'Tuya Garage'), 'mdi:garage-variant');
|
||||
assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator');
|
||||
assert.equal(iconFor('Неизвестное', 'XYZ'), 'mdi:chip');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Юнит-тесты чистой валидации House Plan (загружаем validation.py по пути,
|
||||
без импорта пакета HA-интеграции)."""
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
"custom_components", "houseplan", "validation.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("hp_validation", _PATH)
|
||||
v = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(v)
|
||||
|
||||
|
||||
def test_sanitize_marker_id():
|
||||
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
|
||||
assert v.sanitize_marker_id("..") == "misc" # чистый traversal → misc
|
||||
assert v.sanitize_marker_id(".") == "misc"
|
||||
assert v.sanitize_marker_id("") == "misc"
|
||||
assert len(v.sanitize_marker_id("a" * 200)) == 64
|
||||
|
||||
|
||||
def test_sanitize_filename_strips_path():
|
||||
assert v.sanitize_filename("/a/b/c/manual.pdf") == "manual.pdf"
|
||||
assert v.sanitize_filename("..\\..\\evil.pdf") == "evil.pdf" # обратные слэши = путь
|
||||
assert v.sanitize_filename("...hidden.pdf") == "hidden.pdf" # ведущие точки убраны
|
||||
|
||||
|
||||
def test_file_ext():
|
||||
assert v.file_ext("manual.PDF") == "pdf"
|
||||
assert v.file_ext("a/b/x.png") == "png"
|
||||
assert v.file_ext("noext") == ""
|
||||
|
||||
|
||||
def test_valid_space_id():
|
||||
assert v.valid_space_id("f1")
|
||||
assert v.valid_space_id("floor-2_a")
|
||||
assert not v.valid_space_id("Floor 1")
|
||||
assert not v.valid_space_id("a" * 65)
|
||||
assert not v.valid_space_id("../x")
|
||||
|
||||
|
||||
def test_room_schema_poly_or_rect():
|
||||
v.ROOM_SCHEMA({"id": "r1", "name": "A", "poly": [[0, 0], [1, 0], [1, 1]]})
|
||||
v.ROOM_SCHEMA({"id": "r2", "name": "B", "x": 0, "y": 0, "w": 1, "h": 1})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({"id": "r3", "name": "C"})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
|
||||
|
||||
|
||||
def test_space_schema_aspect_range():
|
||||
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "aspect": 0})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
|
||||
|
||||
|
||||
def test_marker_schema():
|
||||
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
|
||||
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",
|
||||
"pdfs": [{"name": "a.pdf", "url": "/u/a.pdf"}]})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({"binding": "virtual"})
|
||||
|
||||
|
||||
def test_config_schema_defaults_and_extra():
|
||||
out = v.CONFIG_SCHEMA({"spaces": []})
|
||||
assert out["markers"] == [] and out["settings"] == {}
|
||||
out2 = v.CONFIG_SCHEMA({"spaces": [], "virtual_devices": [], "device_overrides": {}})
|
||||
assert "spaces" in out2
|
||||
|
||||
|
||||
def test_config_schema_full_roundtrip():
|
||||
cfg = {
|
||||
"spaces": [{
|
||||
"id": "f1", "title": "1 этаж", "plan_url": "/p/f1.svg",
|
||||
"aspect": 0.8, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r1", "name": "Зал", "area": "hall",
|
||||
"poly": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}],
|
||||
"segments": [[0, 0, 0.5, 0]],
|
||||
}],
|
||||
"markers": [{"id": "d1", "binding": "device:x", "model": "M", "link": "https://e.com"}],
|
||||
"settings": {"group_lights": True},
|
||||
}
|
||||
out = v.CONFIG_SCHEMA(cfg)
|
||||
assert out["spaces"][0]["rooms"][0]["area"] == "hall"
|
||||
assert out["markers"][0]["binding"] == "device:x"
|
||||
|
||||
|
||||
def test_layout_schema():
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
|
||||
"include": ["src/logic.ts", "src/rules.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user