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:
JB
2026-07-04 13:42:03 +03:00
parent e61d2c173f
commit e5e02faa6c
25 changed files with 871 additions and 884 deletions
+17 -2
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
node_modules/
tsout/
test-build/
*.log
__pycache__/
.pytest_cache/
View File
+2 -3
View File
@@ -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
+10 -4
View File
@@ -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"
}
+119
View File
@@ -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, # неизвестные ключи (легаси) не ломают загрузку
)
+7 -102
View File
@@ -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):
+179 -152
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -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
- Поле «Комната» в диалоге устройства теперь для ВСЕХ значков (не только виртуальных):
для привязанных — «переопределить размещение» (по умолчанию по зоне устройства); смена
+10
View File
@@ -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
View File
@@ -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
-68
View File
@@ -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);
}
-140
View File
@@ -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": "Двор / Участок"
};
-63
View File
@@ -1,63 +0,0 @@
/**
* Правила прототипа перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.24.5).
*/
/** Интеграции-домены, чьи устройства скрываются (курирование). */
export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
'upnp_serial_number',
]);
/** Заголовки групп ламп по area_id. */
export const GROUP_TITLES: Record<string, string> = {
gostinaia_na_dache: 'Настенные лампы',
prikhozhaia: 'Свет в прихожей',
detskaia_maiia: 'Свет у Майи',
detskaia_elina: 'Свет у Элины',
};
const ICON_RULES: Array<[string, string]> = [
['протечк', 'mdi:water-alert'],
['клапан', 'mdi:pipe-valve'],
['дым', 'mdi:smoke-detector'],
['термоголов', 'mdi:radiator'],
['температ', 'mdi:thermometer'],
['qingping|air monitor|молекул', 'mdi:air-filter'],
['штор', 'mdi:roller-shade'],
['розетк|plug', 'mdi:power-socket-de'],
['выключат|switch', 'mdi:light-switch'],
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'],
['камер|camera', 'mdi:cctv'],
['замок|ttlock|lock|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
View File
@@ -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;
+57
View File
@@ -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
View File
@@ -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',
];
+69
View File
@@ -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');
});
+99
View File
@@ -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}})
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
"include": ["src/logic.ts", "src/rules.ts"]
}