refactor+fix v1.10.0: аудит — гонки записи (asyncio.Lock, атомарный rev), точечный layout/update вместо layout/set (анти last-writer-wins), layout/delete, safeUrl против XSS в link/pdfs, fetchWithAuth, KEY_HASS, стриминговый лимит upload, модульность (styles/types/devices), динамические пространства в GUI-редакторе, чистка мёртвого кода (file/set, GROUP_TITLES)

This commit is contained in:
Matysh
2026-07-05 20:58:33 +03:00
parent 7cd9a56f5a
commit e7cf0416be
16 changed files with 1991 additions and 1874 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.9.3" VERSION = "1.10.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+28 -9
View File
@@ -11,6 +11,11 @@ from pathlib import Path
from aiohttp import web from aiohttp import web
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
try: # KEY_HASS — современный доступ к hass из aiohttp-приложения
from homeassistant.components.http import KEY_HASS
except ImportError: # старые версии HA
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL
@@ -24,6 +29,8 @@ from .validation import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
class HouseplanUploadView(HomeAssistantView): class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — сохранить файл маркера, вернуть URL.""" """POST /api/houseplan/upload — сохранить файл маркера, вернуть URL."""
@@ -33,7 +40,7 @@ class HouseplanUploadView(HomeAssistantView):
requires_auth = True requires_auth = True
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app["hass"] hass: HomeAssistant = request.app[KEY_HASS]
entry = hass.data.get(DOMAIN, {}).get("entry") entry = hass.data.get(DOMAIN, {}).get("entry")
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only: if admin_only:
@@ -44,6 +51,7 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = "misc" marker_id = "misc"
filename: str | None = None filename: str | None = None
blob: bytes | None = None blob: bytes | None = None
too_large = False
try: try:
reader = await request.multipart() reader = await request.multipart()
async for part in reader: async for part in reader:
@@ -51,11 +59,26 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = sanitize_marker_id(await part.text()) marker_id = sanitize_marker_id(await part.text())
elif part.name == "file": elif part.name == "file":
filename = part.filename or "file" filename = part.filename or "file"
blob = await part.read(decode=False) # читаем чанками с обрывом по лимиту, а не весь файл в память
chunks: list[bytes] = []
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
break
chunks.append(chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err) _LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err)
return web.json_response({"error": "bad_request"}, status=400) return web.json_response({"error": "bad_request"}, status=400)
if too_large:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if blob is None or not filename: if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400) return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename) ext = file_ext(filename)
@@ -63,21 +86,17 @@ class HouseplanUploadView(HomeAssistantView):
return web.json_response( return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400 {"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
) )
if len(blob) > MAX_FILE_BYTES:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
safe_name = sanitize_filename(filename) safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name path = target_dir / safe_name
def _write() -> None: def _write() -> int:
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob) path.write_bytes(blob)
return int(path.stat().st_mtime)
await hass.async_add_executor_job(_write) mtime = await hass.async_add_executor_job(_write)
mtime = int(path.stat().st_mtime)
return web.json_response( return web.json_response(
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename} {"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
) )
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"version": "1.9.3" "version": "1.10.0"
} }
+57 -66
View File
@@ -1,6 +1,7 @@
"""WS-команды House Plan: раскладка, конфигурация пространств, загрузка планов.""" """WS-команды House Plan: раскладка, конфигурация пространств, загрузка планов."""
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import binascii import binascii
from pathlib import Path from pathlib import Path
@@ -13,25 +14,24 @@ from homeassistant.core import HomeAssistant, callback
from .const import ( from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN,
FILES_DIR, FILES_URL, PLANS_DIR, PLANS_URL, PLANS_DIR, PLANS_URL,
) )
from .validation import ( from .validation import (
CONFIG_SCHEMA, FILE_EXTENSIONS, LAYOUT_SCHEMA, MAX_FILE_BYTES, MAX_PLAN_BYTES, CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, file_ext, sanitize_filename, sanitize_marker_id, valid_space_id, PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
) )
@callback @callback
def async_register(hass: HomeAssistant) -> None: def async_register(hass: HomeAssistant) -> None:
"""Регистрация WS-команд.""" """Регистрация WS-команд."""
websocket_api.async_register_command(hass, ws_layout_get) websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_layout_set) websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_layout_update) websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set) websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set) websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_file_set)
def _store(hass: HomeAssistant): def _store(hass: HomeAssistant):
@@ -42,6 +42,15 @@ def _config_store(hass: HomeAssistant):
return hass.data[DOMAIN]["config_store"] return hass.data[DOMAIN]["config_store"]
def _write_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Единый лок на цикл load→modify→save обоих хранилищ.
Без него параллельные WS-вызовы теряют изменения (last-writer-wins),
а проверка expected_rev неатомарна.
"""
return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock())
def _check_write(hass: HomeAssistant, connection) -> bool: def _check_write(hass: HomeAssistant, connection) -> bool:
entry = hass.data[DOMAIN].get("entry") entry = hass.data[DOMAIN].get("entry")
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
@@ -68,7 +77,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам")
return return
await _store(hass).async_save({"layout": msg["layout"]}) async with _write_lock(hass):
await _store(hass).async_save({"layout": msg["layout"]})
connection.send_result(msg["id"], {"ok": True}) connection.send_result(msg["id"], {"ok": True})
@@ -86,10 +96,33 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам")
return return
store = _store(hass) store = _store(hass)
data = await store.async_load() or {} async with _write_lock(hass):
layout = data.get("layout", {}) data = await store.async_load() or {}
layout[msg["device_id"]] = msg["pos"] layout = data.get("layout", {})
await store.async_save({"layout": layout}) layout[msg["device_id"]] = msg["pos"]
await store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/delete",
vol.Required("device_id"): str,
}
)
@websocket_api.async_response
async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Удалить позицию одного устройства (чистка при удалении маркера)."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам")
return
store = _store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True}) connection.send_result(msg["id"], {"ok": True})
@@ -124,16 +157,17 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам") connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам")
return return
store = _config_store(hass) store = _config_store(hass)
data = await store.async_load() or {} async with _write_lock(hass):
current_rev = data.get("rev", 0) data = await store.async_load() or {}
if "expected_rev" in msg and msg["expected_rev"] != current_rev: current_rev = data.get("rev", 0)
connection.send_error( if "expected_rev" in msg and msg["expected_rev"] != current_rev:
msg["id"], "conflict", connection.send_error(
f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})", msg["id"], "conflict",
) f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})",
return )
new_rev = current_rev + 1 return
await store.async_save({"config": msg["config"], "rev": new_rev}) new_rev = current_rev + 1
await store.async_save({"config": msg["config"], "rev": new_rev})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev}) hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -171,7 +205,7 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
plans_dir = Path(hass.config.path(PLANS_DIR)) plans_dir = Path(hass.config.path(PLANS_DIR))
path = plans_dir / f"{space_id}.{msg['ext']}" path = plans_dir / f"{space_id}.{msg['ext']}"
def _write() -> None: def _write() -> int:
plans_dir.mkdir(parents=True, exist_ok=True) plans_dir.mkdir(parents=True, exist_ok=True)
# убрать старые варианты с другим расширением # убрать старые варианты с другим расширением
for old_ext in PLAN_EXTENSIONS: for old_ext in PLAN_EXTENSIONS:
@@ -179,52 +213,9 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
if old_ext != msg["ext"] and old.exists(): if old_ext != msg["ext"] and old.exists():
old.unlink() old.unlink()
path.write_bytes(raw) path.write_bytes(raw)
return int(path.stat().st_mtime)
await hass.async_add_executor_job(_write) mtime = await hass.async_add_executor_job(_write)
mtime = int(path.stat().st_mtime)
connection.send_result( connection.send_result(
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"} msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
) )
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/file/set",
vol.Required("marker_id"): str,
vol.Required("filename"): str,
vol.Required("data"): str, # base64
}
)
@websocket_api.async_response
async def ws_file_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Загрузить файл-инструкцию (PDF и т.п.) для маркера; вернуть URL."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Загрузка файлов разрешена только администраторам")
return
marker_id = sanitize_marker_id(msg["marker_id"])
raw_name = msg["filename"].rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
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 = sanitize_filename(raw_name)
try:
blob = base64.b64decode(msg["data"], validate=True)
except (binascii.Error, ValueError):
connection.send_error(msg["id"], "invalid_data", "data должен быть корректным base64")
return
if len(blob) > MAX_FILE_BYTES:
connection.send_error(msg["id"], "too_large", f"Файл больше {MAX_FILE_BYTES // 1024 // 1024} МБ")
return
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
def _write() -> None:
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
await hass.async_add_executor_job(_write)
mtime = int(path.stat().st_mtime)
connection.send_result(
msg["id"], {"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": raw_name}
)
+316 -316
View File
File diff suppressed because one or more lines are too long
+29
View File
@@ -1,5 +1,34 @@
# Changelog # Changelog
## v1.10.0 — 2026-07-05 (аудит: гонки записи, XSS, модульность)
**Бэкенд**
- **Гонка записи устранена**: все циклы load→modify→save (`layout/set|update|delete`,
`config/set`) сериализованы общим `asyncio.Lock` — параллельные WS-вызовы больше не теряют
изменения, проверка `expected_rev` стала атомарной.
- Новая WS-команда `houseplan/layout/delete` — чистка позиции при удалении маркера
(раньше в `.storage/houseplan.layout` копились сироты).
- Удалена мёртвая WS-команда `houseplan/file/set` — файлы грузятся только по HTTP
(`/api/houseplan/upload`), как и было в карточке.
- HTTP-загрузка: лимит 25 МБ применяется ПО МЕРЕ чтения multipart (обрыв на лимите),
а не после чтения всего файла в память; `stat()` файлов ушёл в executor.
- `request.app[KEY_HASS]` вместо устаревшего `request.app["hass"]` (с фолбэком для старых HA).
**Фронтенд**
- **God-component разобран**: `houseplan-card.ts` 3023 → ~1990 строк.
Стили → `styles.ts`; типы → `types.ts`; построение устройств из реестров HA
(курирование, группы света, маркеры, LQI/температура) → `devices.ts` (чистые функции).
- **Last-writer-wins в раскладке устранён**: `_saveMarker` пишет позицию точечно
(`layout/update`), а не всей раскладкой (`layout/set`) — не затирает позиции из других
окон (регресс-класс инцидента v1.4.4). Перепривязка/удаление подчищают старую позицию.
- **XSS закрыт**: `link` и `pdfs[].url` маркеров проходят `safeUrl()` (только http(s)
и относительные пути; `javascript:`/`data:` отбрасываются). +12 тестов.
- Загрузка файлов через `hass.fetchWithAuth` (автообновление протухшего токена),
фолбэк на сырой токен.
- **Хардкод дачи убран из GUI-редактора**: список «пространство по умолчанию» строится
из серверного конфига (WS `config/get`), а не зашитых f1/f2/yard.
- `rules.ts`: удалён мёртвый экспорт `GROUP_TITLES`; регэкспы `iconFor` прекомпилированы.
- Единый `_errText()` во всех обработчиках ошибок (никогда «[object Object]»).
## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты) ## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты)
- Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков) - Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков)
вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов). вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов).
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.9.3", "version": "1.10.0",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
+275
View File
@@ -0,0 +1,275 @@
/**
* Построение списка устройств из реестров HA: курирование, группы света,
* маркеры (оверрайды/виртуальные). Без Lit/DOM только hass-объект.
*/
import { iconFor, DOMAIN_PRIORITY } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
/** Контекст построения: срез hass + резолв конфига. */
export interface BuildCtx {
hass: any;
/** area_id → space_id (только зоны, привязанные к комнатам). */
areaToSpace: Record<string, string>;
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set<string>;
showAll: boolean;
firstSpaceId: string;
}
export function entitiesByDevice(hass: any): Record<string, string[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(hass.entities)) {
if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
}
return map;
}
export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
for (const eid of entIds) {
const p = hass.entities[eid]?.platform;
if (p) return p;
}
return '';
}
export function isTempEntity(hass: any, eid: string): boolean {
const st = hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
return (
a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid)
);
}
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
const ents = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden);
const usable = ents.filter((e) => !e.reg.entity_category);
const pool = usable.length ? usable : ents;
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => isTempEntity(hass, e.eid));
if (t) return t.eid;
}
for (const dom of DOMAIN_PRIORITY) {
const found = pool.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
return pool[0]?.eid;
}
/** Средний LQI zigbee по сущностям устройства (сенсоры *_linkquality/*_lqi либо атрибут). */
export function lqiFor(hass: any, entIds: string[]): number | null {
const vals: number[] = [];
for (const eid of entIds) {
const st = hass.states[eid];
if (!st) continue;
const unit = (st.attributes?.unit_of_measurement || '').toLowerCase();
// 1) выделенный сенсор сигнала: Z2M *_linkquality, ZHA *_lqi, либо единицы «lqi»
if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') {
const v = parseFloat(st.state);
if (!isNaN(v)) vals.push(v);
continue;
}
// 2) сигнал как АТРИБУТ на любой сущности устройства (Z2M linkquality / ZHA lqi) —
// покрывает устройства, у которых отдельный сенсор сигнала отключён
const av = st.attributes?.linkquality ?? st.attributes?.lqi;
if (av != null) {
const v = parseFloat(av);
if (!isNaN(v)) vals.push(v);
}
}
return averageLqi(vals);
}
export function tempFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!isTempEntity(hass, eid)) continue;
const st = hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** Групповые световые сущности: HA light-group (platform=group) и Z2M-группы (device model=Group). */
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
if (!enabled) return [];
const res: { eid: string; name: string; area: string }[] = [];
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
if (!eid.startsWith('light.') || reg.hidden) continue;
let area: string | null = null;
if (reg.platform === 'group') {
area = reg.area_id || null;
} else if (reg.device_id) {
const dev = hass.devices[reg.device_id];
if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null;
else continue;
} else {
continue;
}
if (!area) continue;
const st = hass.states[eid];
res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area });
}
return res;
}
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model;
item.link = m.link ?? null;
item.description = m.description ?? null;
item.pdfs = m.pdfs || [];
}
/** Курирование + группы света + маркеры (метаданные/перепривязка) + виртуальные. Гибрид. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const claimed = new Set<string>();
for (const m of markers) {
const [kind, ref] = m.binding.split(':');
if ((kind === 'device' || kind === 'entity') && ref) claimed.add(m.binding);
}
const markerFor = (kind: string, ref: string) => markers.find((m) => m.binding === kind + ':' + ref);
const seen: Record<string, number> = {};
const rest: DevItem[] = [];
// 1) авто-устройства HA (не занятые маркером, не скрытые)
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже
const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
// курирование (можно отключить переключателем «показать все»)
if (!showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
if (dom === 'myheat' && dev.via_device_id) continue;
}
const name = (dev.name_by_user || dev.name || 'без имени').trim();
const key = name + '|' + area;
let icon = iconFor(name, dev.model);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// дубли по «имя|зона» не скрываем, а нумеруем
seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
const item: DevItem = {
id: dev.id,
name: dispName,
model: dev.model || '',
area,
space: areaToSpace[area],
icon,
entities: entIds,
bindingKind: 'device',
bindingRef: dev.id,
pdfs: [],
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
rest.push(item);
}
// 2) группы света (не занятые маркером)
for (const g of groups) {
if (!areaToSpace[g.area]) continue;
if (claimed.has('entity:' + g.eid)) continue;
rest.push({
id: 'lg_' + g.eid,
name: g.name,
model: 'группа света',
area: g.area,
space: areaToSpace[g.area],
icon: 'mdi:lightbulb-group',
entities: [g.eid],
primary: g.eid,
bindingKind: 'entity',
bindingRef: g.eid,
pdfs: [],
});
}
// 3) явные маркеры (перепривязка/метаданные/виртуальные)
for (const m of markers) {
if (m.hidden) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
const area = m.area || dev?.area_id || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const entIds = dev ? entsBy[dev.id] || [] : [];
let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle';
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
name: dev?.name_by_user || dev?.name || 'устройство',
model: dev?.model || '',
area,
space,
icon,
entities: entIds,
bindingKind: 'device',
bindingRef: ref,
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
const reg = h.entities[ref];
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const st = h.states[ref];
const item: DevItem = {
id: m.id,
name: reg?.name || st?.attributes?.friendly_name || ref,
model: '',
area,
space,
icon: 'mdi:shape-outline',
entities: [ref],
primary: ref,
bindingKind: 'entity',
bindingRef: ref,
};
applyMarker(item, m);
rest.push(item);
} else {
// виртуальный
const area = m.area || '';
const space = m.space || (area && areaToSpace[area]) || firstSpaceId;
const item: DevItem = {
id: m.id,
name: m.name || 'виртуальное устройство',
model: m.model || '',
area,
space,
icon: m.icon || 'mdi:map-marker',
entities: [],
bindingKind: 'virtual',
virtual: true,
};
applyMarker(item, m);
rest.push(item);
}
}
return rest;
}
+40 -23
View File
@@ -1,30 +1,9 @@
/** Редактор конфигурации карточки (GUI в Lovelace). */ /** Редактор конфигурации карточки (GUI в Lovelace). */
import { LitElement, html, nothing } from 'lit'; 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> = { const LABELS: Record<string, string> = {
title: 'Заголовок', title: 'Заголовок',
default_floor: 'Этаж по умолчанию', default_floor: 'Пространство по умолчанию',
icon_size: 'Размер иконок, % ширины плана', icon_size: 'Размер иконок, % ширины плана',
show_temperature: 'Показывать температуру', show_temperature: 'Показывать температуру',
live_states: 'Живые состояния (вкл/выкл, открыто…)', live_states: 'Живые состояния (вкл/выкл, открыто…)',
@@ -34,22 +13,60 @@ const LABELS: Record<string, string> = {
class HouseplanCardEditor extends LitElement { class HouseplanCardEditor extends LitElement {
public hass?: any; public hass?: any;
private _config?: any; private _config?: any;
private _spaces: { value: string; label: string }[] | null = null;
private _spacesLoading = false;
static properties = { static properties = {
hass: { attribute: false }, hass: { attribute: false },
_config: { state: true }, _config: { state: true },
_spaces: { state: true },
}; };
public setConfig(config: any): void { public setConfig(config: any): void {
this._config = config; this._config = config;
} }
/** Пространства из серверного конфига интеграции — не хардкод. */
private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true;
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({
value: s.id,
label: s.title || s.id,
}));
} catch {
this._spaces = [];
} finally {
this._spacesLoading = false;
}
}
private get _schema(): any[] {
const spaces = this._spaces || [];
return [
{ name: 'title', selector: { text: {} } },
spaces.length
? {
name: 'default_floor',
selector: { select: { mode: 'dropdown', options: spaces } },
}
: { name: 'default_floor', selector: { text: {} } },
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
];
}
protected render() { protected render() {
if (!this.hass || !this._config) return nothing; if (!this.hass || !this._config) return nothing;
this._loadSpaces();
return html`<ha-form return html`<ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._config} .data=${this._config}
.schema=${SCHEMA} .schema=${this._schema}
.computeLabel=${(s: any) => LABELS[s.name] || s.name} .computeLabel=${(s: any) => LABELS[s.name] || s.name}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form>`; ></ha-form>`;
+66 -1103
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -98,3 +98,17 @@ export function declump(
if (!moved) break; if (!moved) break;
} }
} }
/**
* Безопасный URL для <a href>: допускаются только http(s) и относительные пути.
* Отсекает javascript:, data: и прочие опасные схемы (XSS через конфиг).
*/
export function safeUrl(url: string | null | undefined): string | null {
if (!url) return null;
const u = url.trim();
if (/^(https?:)?\/\//i.test(u) || u.startsWith('/') || /^[\w./#?=&%~-]+$/i.test(u)) {
if (/^[a-z][\w+.-]*:/i.test(u) && !/^https?:/i.test(u)) return null;
return u;
}
return null;
}
+28 -36
View File
@@ -9,48 +9,40 @@ export const EXCLUDED_DOMAINS = new Set([
'upnp_serial_number', 'upnp_serial_number',
]); ]);
/** Заголовки групп ламп по area_id. */ const ICON_RULES: Array<[RegExp, string]> = [
export const GROUP_TITLES: Record<string, string> = { [/протечк/, 'mdi:water-alert'],
gostinaia_na_dache: 'Настенные лампы', [/клапан/, 'mdi:pipe-valve'],
prikhozhaia: 'Свет в прихожей', [/дым/, 'mdi:smoke-detector'],
detskaia_maiia: 'Свет у Майи', [/термоголов/, 'mdi:radiator'],
detskaia_elina: 'Свет у Элины', [/температ/, 'mdi:thermometer'],
}; [/qingping|air monitor|молекул/, 'mdi:air-filter'],
[/штор/, 'mdi:roller-shade'],
const ICON_RULES: Array<[string, string]> = [ [/розетк|plug/, 'mdi:power-socket-de'],
['протечк', 'mdi:water-alert'], [/выключат|switch/, 'mdi:light-switch'],
[лапан', 'mdi:pipe-valve'], [/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
['дым', 'mdi:smoke-detector'], [/камер|camera/, 'mdi:cctv'],
['термоголов', 'mdi:radiator'], [/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
['температ', 'mdi:thermometer'], [/ворота|garage/, 'mdi:garage-variant'],
['qingping|air monitor|молекул', 'mdi:air-filter'], [/калитк|door|открыт/, 'mdi:door'],
['штор', 'mdi:roller-shade'], [/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
['розетк|plug', 'mdi:power-socket-de'], [/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
['выключат|switch', 'mdi:light-switch'], [/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'], [/холодильник|fridge/, 'mdi:fridge'],
['камер|camera', 'mdi:cctv'], [/стиральн|washer/, 'mdi:washing-machine'],
['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'], [/сушилк|dryer/, 'mdi:tumble-dryer'],
['ворота|garage', 'mdi:garage-variant'], [/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
['калитк|door|открыт', 'mdi:door'], [/soundbar|колонк|станц/, 'mdi:soundbar'],
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'], [/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
['вводный автомат|breaker|wifimcbn', 'mdi:electric-switch'], [/keenetic|роутер|router/, 'mdi:router-wireless'],
['myheat|котёл|котел|boiler|отоплен', 'mdi:water-boiler'], [/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
['холодильник|fridge', 'mdi:fridge'], [/slzb|координат|zigbee/, 'mdi:zigbee'],
['стиральн|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-иконки по имени/модели устройства. */ /** Подбор MDI-иконки по имени/модели устройства. */
export function iconFor(name?: string, model?: string): string { export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase(); const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) { for (const [pat, icon] of ICON_RULES) {
if (new RegExp(pat).test(s)) return icon; if (pat.test(s)) return icon;
} }
return 'mdi:chip'; return 'mdi:chip';
} }
+727
View File
@@ -0,0 +1,727 @@
/** Стили карточки House Plan (вынесены из компонента). */
import { css } from 'lit';
export const cardStyles = css`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
--hp-txt: var(--primary-text-color, #e6edf3);
--hp-muted: var(--secondary-text-color, #8aa0b3);
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
}
ha-card {
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
}
.empty {
padding: 40px 24px;
color: var(--hp-txt);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty .big {
--mdc-icon-size: 56px;
color: var(--hp-accent);
opacity: 0.7;
}
.empty .muted {
color: var(--hp-muted);
font-size: 13px;
margin: 0;
}
.empty .btn {
margin-top: 8px;
}
.hdr {
position: sticky;
top: var(--header-height, 56px);
z-index: 20;
background: var(--card-background-color, var(--hp-bg));
border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0;
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--hp-line);
flex-wrap: wrap;
}
.title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.title ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 18px;
}
.tabs {
display: flex;
gap: 4px;
background: rgba(127, 127, 127, 0.12);
padding: 3px;
border-radius: 10px;
flex-wrap: wrap;
}
@media (max-width: 620px) {
.head { gap: 6px; padding: 8px 10px; }
.head .count { display: none; }
.head .title { font-size: 14px; }
}
.tab {
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 6px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
}
.tab:hover {
color: var(--hp-txt);
}
.tab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
.count {
font-size: 12px;
color: var(--hp-muted);
}
.spacer {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--hp-line);
background: transparent;
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
transition: 0.15s;
font-family: inherit;
font-size: 12.5px;
}
.btn ha-icon {
--mdc-icon-size: 17px;
}
.btn:hover {
border-color: var(--hp-accent);
}
.btn.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
border-color: var(--hp-accent);
}
.btn.ghost {
border: none;
}
.btn[disabled] {
opacity: 0.5;
pointer-events: none;
}
.stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
touch-action: none; /* свои жесты pinch/pan */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.zoomwrap {
position: absolute;
inset: 0;
}
.zoomctl {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 8px;
padding: 2px;
}
.zoomctl .zb {
border: none;
padding: 5px 7px;
}
.zoomctl .zb[disabled] {
opacity: 0.4;
pointer-events: none;
}
.zoombadge {
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
.stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.room {
transition: 0.12s;
cursor: pointer;
}
.room.overlay {
fill: transparent;
stroke: transparent;
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
}
.room.yard:hover {
fill: rgba(75, 140, 90, 0.24);
stroke: #6fbf86;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
font-weight: 600;
pointer-events: none;
text-anchor: middle;
}
.stage.edit .room {
pointer-events: none;
}
.stage.markup {
cursor: crosshair;
}
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer {
display: none; /* в разметке иконки не мешают */
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.4;
}
.seg {
stroke: var(--hp-accent);
stroke-width: 2.5;
stroke-linecap: round;
}
.pathline {
stroke: #ffc14d;
stroke-width: 3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview {
stroke: #ffc14d;
stroke-width: 2;
stroke-dasharray: 6 5;
opacity: 0.7;
}
.vertex {
fill: #ffc14d;
stroke: #4a2800;
stroke-width: 1;
}
.vertex.first {
fill: #4bd28f;
stroke: #04121f;
}
.areasel,
.namein {
background: var(--hp-bg);
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
}
.namein {
width: 130px;
}
.devlayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.dev {
position: absolute;
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: grab;
pointer-events: auto;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
z-index: 2;
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
}
.dev:active {
cursor: grabbing;
}
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev.on {
background: var(--hp-on);
border-color: var(--hp-on);
color: #503c00;
box-shadow: 0 0 8px rgba(255, 212, 92, 0.7);
}
.dev.open {
background: var(--hp-open);
border-color: var(--hp-open);
color: #4a2800;
}
.dev.unavail {
opacity: 0.35;
}
.dev.virtual {
border-style: dashed;
}
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
}
.dev .tval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: #dff1ff;
white-space: nowrap;
pointer-events: none;
}
.dev .lqi {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
pointer-events: none;
}
.editbar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--hp-line);
font-size: 13px;
flex-wrap: wrap;
}
.tab .tabedit {
--mdc-icon-size: 13px;
margin-left: 6px;
opacity: 0.4;
vertical-align: middle;
}
.tab:hover .tabedit {
opacity: 0.9;
}
.tab.tabadd {
padding: 6px 8px;
}
.tab.tabadd ha-icon {
--mdc-icon-size: 15px;
}
.planrow {
display: flex;
align-items: center;
gap: 10px;
}
.planprev {
max-width: 120px;
max-height: 70px;
border: 1px solid var(--hp-line);
border-radius: 6px;
background: #fff;
}
.planname {
font-size: 12.5px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planname.muted {
color: var(--hp-muted);
}
.filebtn {
cursor: pointer;
}
.btn.danger {
border-color: #b3402a;
color: #ff7a5c;
}
.dialog .row .spacer {
flex: 1;
}
.dialog.wide {
width: min(440px, 94vw);
}
.dialog .body {
max-height: 66vh;
overflow-y: auto;
}
.descin {
width: 100%;
box-sizing: border-box;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.bindsel {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--hp-line);
border-radius: 8px;
padding: 8px;
}
.bindsel .opt {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--hp-line);
background: transparent;
color: var(--hp-txt);
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
font-size: 12.5px;
font-family: inherit;
}
.bindsel .opt.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
border-color: var(--hp-accent);
}
.curbind {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--hp-txt);
flex-wrap: wrap;
}
.curbind .ref {
color: var(--hp-muted);
font-size: 11px;
}
.candlist {
max-height: 160px;
overflow-y: auto;
border-top: 1px solid var(--hp-line);
}
.cand {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 6px;
font-size: 12.5px;
}
.cand:hover {
background: rgba(127, 127, 127, 0.15);
}
.cand.sel {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
.cand .cs {
color: var(--hp-muted);
font-size: 11px;
white-space: nowrap;
}
.cand.sel .cs {
color: var(--text-primary-color, #fff);
opacity: 0.85;
}
.cand.muted {
color: var(--hp-muted);
cursor: default;
}
.pdfedit {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.pdftag {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--hp-line);
border-radius: 6px;
padding: 3px 6px;
font-size: 12px;
}
.pdftag a {
color: var(--hp-txt);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdftag .x {
--mdc-icon-size: 15px;
cursor: pointer;
color: var(--hp-muted);
}
.pdftag .x:hover {
color: #ff7a5c;
}
.inforow {
display: flex;
gap: 10px;
font-size: 13px;
margin: 3px 0;
}
.inforow .k {
color: var(--hp-muted);
min-width: 84px;
}
.inforow a {
color: var(--hp-accent);
word-break: break-all;
}
.infodesc {
font-size: 13px;
white-space: pre-wrap;
margin-top: 6px;
}
.infodesc.muted {
color: var(--hp-muted);
}
.pdflist {
display: flex;
flex-direction: column;
gap: 4px;
}
.pdf {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--hp-accent);
text-decoration: none;
}
ha-icon-picker {
display: block;
}
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
width: min(360px, 92vw);
overflow: hidden;
}
.dialog .hd {
padding: 12px 16px;
font-weight: 600;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 8px;
}
.dialog .hd ha-icon {
color: var(--hp-accent);
}
.dialog .body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.dialog .body label {
font-size: 12px;
color: var(--hp-muted);
margin-top: 6px;
}
.dialog .body .namein,
.dialog .body .areasel {
width: 100%;
box-sizing: border-box;
}
.dialog .row {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--hp-line);
}
.editbar .warn {
color: #ffc14d;
}
.editbar .sname {
font-weight: 600;
}
.editbar input {
width: 74px;
background: transparent;
border: 1px solid var(--hp-line);
color: var(--hp-txt);
border-radius: 6px;
padding: 5px 7px;
font-size: 13px;
}
.editbar label,
.editbar .hint {
color: var(--hp-muted);
font-size: 12px;
}
.menuwrap {
position: fixed;
inset: 0;
z-index: 80;
}
.menu {
position: fixed;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
border-radius: 10px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
min-width: 210px;
max-width: 300px;
overflow: hidden;
transform: translate(0, 8px);
}
.menu .hd {
padding: 8px 12px;
font-weight: 600;
font-size: 12.5px;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: 6px;
}
.menu .hd ha-icon,
.menu .it.all ha-icon {
color: var(--hp-accent);
--mdc-icon-size: 16px;
}
.menu .it {
padding: 8px 12px;
font-size: 12.5px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.menu .it ha-icon {
--mdc-icon-size: 16px;
color: var(--hp-muted);
}
.menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.menu .it.all {
color: var(--hp-accent);
font-weight: 600;
}
.tip {
position: fixed;
pointer-events: none;
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 6px 10px;
border-radius: 8px;
font-size: 12.5px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 99;
max-width: 260px;
}
.tip .m {
color: var(--hp-muted);
font-size: 11px;
display: block;
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: 9px 16px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
z-index: 120;
max-width: 90vw;
}
`;
+75
View File
@@ -0,0 +1,75 @@
/** Общие типы карточки House Plan. */
export interface RoomCfg {
id?: string;
name: string;
area: string | null;
x?: number;
y?: number;
w?: number;
h?: number;
poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг)
}
export interface SpaceModel {
id: string;
title: string;
vb: number[]; // render units
bg: { href: string; x: number; y: number; w: number; h: number } | null;
rooms: RoomCfg[]; // render units
}
export interface PdfRef {
name: string;
url: string;
}
/** Маркер конфига: правит/дополняет авто-устройство ИЛИ описывает ручной/виртуальный значок. */
export interface Marker {
id: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
space?: string | null;
area?: string | null;
hidden?: boolean;
name?: string | null;
icon?: string | null;
model?: string | null;
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
}
export interface ServerConfig {
spaces: any[];
markers: Marker[];
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
}
export interface DevItem {
id: string;
name: string;
model: string;
area: string;
space: string;
icon: string;
entities: string[];
primary?: string;
temp?: number | null;
virtual?: boolean;
marker?: Marker; // связанный маркер конфига (метаданные, оверрайды)
bindingKind?: 'device' | 'entity' | 'virtual';
bindingRef?: string; // device_id / entity_id
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
}
export interface CardConfig {
type: string;
title?: string;
default_floor?: string;
icon_size?: number;
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
}
+16 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, fitView, declump, safeUrl,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { iconFor } from '../test-build/rules.js'; import { iconFor } from '../test-build/rules.js';
@@ -112,3 +112,18 @@ test('averageLqi: пусто → null, иначе округлённое сре
assert.equal(averageLqi([100, 200]), 150); assert.equal(averageLqi([100, 200]), 150);
assert.equal(averageLqi([1, 2, 2]), 2); assert.equal(averageLqi([1, 2, 2]), 2);
}); });
test('safeUrl: допускает http(s) и относительные, режет опасные схемы', () => {
assert.equal(safeUrl('https://example.com/a?b=1'), 'https://example.com/a?b=1');
assert.equal(safeUrl('http://x.ru'), 'http://x.ru');
assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf');
assert.equal(safeUrl('/houseplan_files/files/m1/doc.pdf?v=1'), '/houseplan_files/files/m1/doc.pdf?v=1');
assert.equal(safeUrl('docs/manual.pdf'), 'docs/manual.pdf');
assert.equal(safeUrl('javascript:alert(1)'), null);
assert.equal(safeUrl('data:text/html,<script>'), null);
assert.equal(safeUrl('vbscript:x'), null);
assert.equal(safeUrl(''), null);
assert.equal(safeUrl(null), null);
assert.equal(safeUrl(undefined), null);
assert.equal(safeUrl(' https://x.ru '), 'https://x.ru');
});