feat v1.6.0+v1.6.1 (phase 3): device edit mode — markers model (hybrid auto+manual), click info-card in view / edit dialog in edit; dialog: name/binding-picker(filter,exclude placed+name-dupes,+virtual)/mdi-icon/model/link/description/PDF-upload/room; toolbar + add device; backend MARKER_SCHEMA + file/set upload + files static

This commit is contained in:
JB
2026-07-04 13:16:16 +03:00
parent 6d041ab4db
commit 74640318e9
13 changed files with 1471 additions and 150 deletions
+8 -1
View File
@@ -12,6 +12,8 @@ from homeassistant.helpers.storage import Store
from . import websocket_api as hp_ws
from .const import (
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
@@ -41,7 +43,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
await hass.async_add_executor_job(lambda: plans_path.mkdir(parents=True, exist_ok=True))
files_path = Path(hass.config.path(FILES_DIR))
await hass.async_add_executor_job(
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
)
static_paths = []
try:
@@ -50,11 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
await hass.http.async_register_static_paths(static_paths)
except ImportError: # старые версии HA
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if card_path.exists():
add_extra_js_url(hass, f"{FRONTEND_URL}?v={VERSION}")
+3 -1
View File
@@ -7,8 +7,10 @@ STORAGE_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.5.1"
VERSION = "1.6.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+72 -1
View File
@@ -12,7 +12,10 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, PLANS_DIR, PLANS_URL
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)},
@@ -66,6 +69,26 @@ VIRTUAL_SCHEMA = vol.Schema(
},
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],
@@ -80,6 +103,7 @@ CONFIG_SCHEMA = vol.Schema(
)
},
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,
@@ -88,6 +112,9 @@ CONFIG_SCHEMA = vol.Schema(
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
@@ -99,6 +126,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_file_set)
def _store(hass: HomeAssistant):
@@ -252,3 +280,46 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
connection.send_result(
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 = _SAFE_NAME_RE.sub("_", msg["marker_id"])[:64] or "misc"
raw_name = msg["filename"].rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
ext = raw_name.rsplit(".", 1)[-1].lower() if "." in raw_name else ""
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]
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}
)
+324 -56
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -83,6 +83,16 @@ more-info по приоритету доменов), temp, members[] (групп
`.head { position: sticky; top: var(--header-height, 56px) }`; ОБЯЗАТЕЛЬНО
`ha-card { overflow: visible }``overflow: hidden` ломает sticky.
## Маркеры устройств (v1.6.0+)
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. Гибрид: авто-устройства HA
появляются сами; маркер с `binding=device:<id>` их перекрывает (метаданные/перепривязка/скрытие),
`entity:<eid>` — для групп/хелперов, `virtual` — ручной значок без HA. id маркера = device_id /
`lg_<eid>` / `v_<rand>` (сохраняет позицию в layout). Пикер привязки исключает уже размещённые
ссылки и дубли по имя|area. Файлы-инструкции: `houseplan/file/set``/config/houseplan/files/<id>/`,
отдача `/houseplan_files/files/`.
## Серверная конфигурация (v1.3.0+)
`.storage/houseplan.config` (Store):
@@ -116,3 +126,4 @@ more-info по приоритету доменов), temp, members[] (групп
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; событие `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8МБ) | `{ok, url}` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64, ≤25МБ) | `{ok, url, name}` |
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## v1.6.0 — 2026-07-04 (Фаза 3: редактор устройств)
- Модель «маркеров» (config.markers[]): гибрид — авто-устройства HA появляются сами, маркеры
правят/перепривязывают/дополняют их и описывают ручные/виртуальные значки. id маркера
сохраняет позицию (device→device_id, entity→lg_<eid>, virtual→v_<rand>).
- Клик по значку: обычный режим → инфо-карточка (имя, модель, состояние, ссылка, описание,
PDF-инструкции, кнопка «Открыть в HA»); режим правки → диалог редактирования.
- Диалог устройства: имя, привязка (пикер всех устройств/групп HA+Z2M+хелперов с текстовым
фильтром, минус уже размещённые и дубли по имя|area, + «Виртуальное устройство»), MDI-иконка
(ha-icon-picker), модель, ссылка, описание, прикрепление файлов-инструкций (PDF на сервер),
комната (для виртуальных), Убрать/Отмена/Сохранить.
- Тулбар: кнопка «+» (тултип «Добавить устройство») — пустой диалог.
- Бэкенд: MARKER_SCHEMA в config; WS houseplan/file/set (файлы в /config/houseplan/files/,
≤25 МБ, отдача /houseplan_files/files/); статика files зарегистрирована.
- Виртуальные значки теперь перетаскиваются и позиционируются как обычные (layout по id).
## v1.6.1 — 2026-07-04
- Пикер привязки прячет дубли устройств по «имя|area» (Tuya/LocalTuya), как дедуп на плане.
## v1.5.1 — 2026-07-04 (управление пространствами)
- Иконка-карандаш справа от названия каждого пространства в верхнем тулбаре + кнопка «+»
(только при server config).
+2 -2
View File
@@ -42,13 +42,13 @@
- Позже: полигональные комнаты (SVG path), повороты планов.
- Загрузка плана из UI (file upload → WS) + выбор существующего media.
## Фаза 3 — Управление устройствами
## Фаза 3 — Управление устройствами ← ✅ СДЕЛАНО в v1.6.x (скрытие, иконка, имя, модель, ссылка, описание, PDF, перепривязка, «+», виртуальные)
- Панель устройств в режиме настройки: список неразмещённых (с фильтрами), drag из панели
на план; авто-раскладка «сеткой по комнате» кнопкой.
- Оверрайды per-device: скрыть, своя иконка (ha-icon-picker), своё имя. Хранение в config.
- Настраиваемое курирование: исключения интеграций/доменов в options flow вместо хардкода.
## Фаза 4 — Виртуальные устройства
## Фаза 4 — Виртуальные устройства ← ✅ базово в v1.6.x (CRUD виртуальных маркеров в общем диалоге; отдельная доработка заметок/иконок по мере надобности)
- CRUD виртуальных маркеров (имя, иконка, координаты, заметка; опционально ссылка на
entity/URL): септик, кран, счётчик без датчика и т.п. Рендер как обычные иконки,
клик → карточка с заметкой или more-info привязанной сущности.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.5.1",
"version": "1.6.1",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT",
"type": "module",
+708 -32
View File
@@ -12,7 +12,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
import './editor';
const CARD_VERSION = '1.5.1';
const CARD_VERSION = '1.6.1';
const LS_KEY = 'houseplan_card_layout_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
@@ -55,10 +55,31 @@ interface VirtualDevice {
entity_id?: string | null;
}
interface PdfRef {
name: string;
url: string;
}
/** Маркер конфига: правит/дополняет авто-устройство ИЛИ описывает ручной/виртуальный значок. */
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[];
}
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 };
}
@@ -73,6 +94,12 @@ interface DevItem {
primary?: string;
temp?: number | null;
virtual?: VirtualDevice;
marker?: Marker; // связанный маркер конфига (метаданные, оверрайды)
bindingKind?: 'device' | 'entity' | 'virtual';
bindingRef?: string; // device_id / entity_id
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
}
interface CardConfig {
@@ -141,6 +168,20 @@ class HouseplanCard extends LitElement {
private _areaSel = '';
private _nameSel = '';
private _roomDialog = false;
private _infoCard: DevItem | null = null;
private _markerDialog: {
devId?: string; // редактируемый значок (если есть)
name: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = авто
model: string;
link: string;
description: string;
pdfs: PdfRef[];
room: string; // 'space#area' для виртуального
busy: boolean;
} | null = null;
private _spaceDialog: {
mode: 'edit' | 'create';
spaceId?: string;
@@ -173,6 +214,8 @@ class HouseplanCard extends LitElement {
_nameSel: { state: true },
_roomDialog: { state: true },
_spaceDialog: { state: true },
_infoCard: { state: true },
_markerDialog: { state: true },
};
public connectedCallback(): void {
@@ -190,6 +233,10 @@ class HouseplanCard extends LitElement {
}
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
if (this._infoCard) { this._infoCard = null; return; }
if (this._markerDialog) { this._markerDialog = null; return; }
}
if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
if (!undo) return;
@@ -516,6 +563,36 @@ class HouseplanCard extends LitElement {
}
/** Курирование + группы света + оверрайды + виртуальные устройства. */
private get _markers(): Marker[] {
return this._serverCfg?.markers || [];
}
/** Маркер по «claim key»: device:<id> / entity:<eid>. */
private _markerFor(kind: 'device' | 'entity', ref: string): Marker | undefined {
return this._markers.find((m) => m.binding === kind + ':' + ref);
}
/** Множество HA-ссылок (device_id/entity_id), «занятых» маркерами. */
private get _claimed(): Set<string> {
const set = new Set<string>();
for (const m of this._markers) {
const [kind, ref] = m.binding.split(':');
if ((kind === 'device' || kind === 'entity') && ref) set.add(m.binding);
}
return set;
}
private _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 || [];
}
/** Курирование + группы света + маркеры (метаданные/перепривязка) + виртуальные. Гибрид. */
private _buildDevices(): DevItem[] {
const h = this.hass;
const areaMap = this._areaToSpace;
@@ -524,14 +601,19 @@ class HouseplanCard extends LitElement {
const groupedAreas = this._groupedLightAreas();
const excluded = this._excluded;
const entsBy = this._entitiesByDevice();
const claimed = this._claimed;
const seen: Record<string, 1> = {};
const out: DevItem[] = [];
const rest: DevItem[] = [];
// 1) авто-устройства HA (не занятые маркером, не скрытые)
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaMap[area]) continue;
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже
const ov = overrides[dev.id] || {};
if (ov.hidden) continue;
const marker = this._markerFor('device', dev.id);
if ((marker && marker.hidden) || ov.hidden) continue;
const entIds = entsBy[dev.id] || [];
const dom = this._domainOfDevice(dev, entIds);
if (excluded.has(dom)) continue;
@@ -546,7 +628,6 @@ class HouseplanCard extends LitElement {
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,
@@ -556,15 +637,19 @@ class HouseplanCard extends LitElement {
space: areaMap[area].space,
icon,
entities: entIds,
bindingKind: 'device',
bindingRef: dev.id,
pdfs: [],
};
item.primary = this._primaryEntity(entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
out.push(item);
rest.push(item);
}
// группы света: реальные сущности групп HA (platform group) и Z2M (device model=Group)
const rest: DevItem[] = [...out];
// 2) группы света (не занятые маркером)
for (const g of this._lightGroups()) {
if (!areaMap[g.area]) continue;
if (claimed.has('entity:' + g.eid)) continue;
rest.push({
id: 'lg_' + g.eid,
name: g.name,
@@ -574,21 +659,75 @@ class HouseplanCard extends LitElement {
icon: 'mdi:lightbulb-group',
entities: [g.eid],
primary: g.eid,
bindingKind: 'entity',
bindingRef: g.eid,
pdfs: [],
});
}
// виртуальные устройства
for (const v of this._serverCfg?.virtual_devices || []) {
rest.push({
id: 'virt_' + v.id,
name: v.name,
model: v.note || 'виртуальное устройство',
area: '',
space: v.space,
icon: v.icon,
entities: v.entity_id ? [v.entity_id] : [],
primary: v.entity_id || undefined,
virtual: v,
});
// 3) явные маркеры (перепривязка/метаданные/виртуальные)
for (const m of this._markers) {
if (m.hidden) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
const area = dev?.area_id || m.area || '';
const space = (area && areaMap[area]?.space) || m.space || this._model[0]?.id || '';
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 = this._primaryEntity(entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds);
this._applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
const reg = h.entities[ref];
const area = reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || m.area || '';
const space = (area && areaMap[area]?.space) || m.space || this._model[0]?.id || '';
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,
};
this._applyMarker(item, m);
rest.push(item);
} else {
// виртуальный
const area = m.area || '';
const space = m.space || (area && areaMap[area]?.space) || this._model[0]?.id || '';
const item: DevItem = {
id: m.id,
name: m.name || 'виртуальное устройство',
model: m.model || '',
area,
space,
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 },
};
this._applyMarker(item, m);
rest.push(item);
}
}
return rest;
}
@@ -622,11 +761,6 @@ class HouseplanCard extends LitElement {
/** Позиция устройства в рендер-единицах текущего пространства. */
private _pos(d: DevItem): { x: number; y: number } {
const s = this._spaceModel(d.space);
const H = s.vb ? undefined : undefined;
if (d.virtual) {
const height = this._norm ? NORM_W / (this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1) : 0;
return { x: d.virtual.x * (this._norm ? NORM_W : 1), y: d.virtual.y * (this._norm ? height : 1) };
}
const saved = this._layout[d.id];
if (saved) {
if (this._norm) {
@@ -701,15 +835,13 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved) return;
if (this._markup) return;
if (this._edit) {
this._selId = d.id;
this._openMarkerDialog(d);
return;
}
if (d.virtual && !d.primary) {
this._showToast(d.name + (d.virtual.note ? ' — ' + d.virtual.note : ''));
return;
}
this._openMoreInfo(d.primary);
this._infoCard = d;
}
private _clickRoom(r: RoomCfg): void {
@@ -718,7 +850,7 @@ class HouseplanCard extends LitElement {
}
private _pointerDown(ev: PointerEvent, d: DevItem): void {
if (!this._edit || d.virtual) return;
if (!this._edit) return;
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
@@ -1012,6 +1144,234 @@ class HouseplanCard extends LitElement {
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
// ================= РЕДАКТОР УСТРОЙСТВ (маркеры) =================
private _openMarkerDialog(d?: DevItem): void {
if (!this._norm) {
this._showToast('Редактирование устройств доступно после переноса конфига на сервер');
return;
}
if (d) {
this._markerDialog = {
devId: d.id,
name: d.name,
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '',
icon: d.marker?.icon || '',
model: d.model || '',
link: d.link || '',
description: d.description || '',
pdfs: [...(d.pdfs || [])],
room: d.space && d.area ? d.space + '#' + d.area : '',
busy: false,
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
}
/** Кандидаты привязки: устройства HA + группы/хелпер-сущности, минус уже размещённые. */
private _bindingCandidates(): { value: string; label: string; sub: string }[] {
const h = this.hass;
const taken = new Set<string>();
for (const dev of this._devices) {
if (dev.id === this._markerDialog?.devId) continue;
if (dev.bindingKind === 'device' && dev.bindingRef) taken.add('device:' + dev.bindingRef);
if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add('entity:' + dev.bindingRef);
}
// дедуп как на плане: скрыть устройства с тем же «имя|area», что уже показаны (дубли Tuya)
const shownKeys = new Set<string>();
for (const dev of this._devices) {
if (dev.bindingKind === 'device' && dev.name) shownKeys.add(dev.name.trim() + '|' + (dev.area || ''));
}
const list: { value: string; label: string; sub: string }[] = [];
// устройства (в т.ч. Z2M-группы model=Group)
for (const dev of Object.values<any>(h.devices)) {
if (dev.entry_type === 'service') continue;
const v = 'device:' + dev.id;
if (taken.has(v)) continue;
const name = (dev.name_by_user || dev.name || dev.id).trim();
if (v !== this._markerDialog?.binding && shownKeys.has(name + '|' + (dev.area_id || ''))) continue;
list.push({ value: v, label: name, sub: (dev.model || 'устройство') + (dev.model === 'Group' ? ' · Z2M-группа' : '') });
}
// group/helper-сущности без «своего» физического устройства
const helperPlatforms = new Set([
'group', 'template', 'derivative', 'min_max', 'threshold', 'integration',
'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule',
]);
for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v)) continue;
const isHelper = helperPlatforms.has(reg.platform);
const isGroupEntity = reg.platform === 'group';
if (!isHelper && !isGroupEntity) continue;
if (reg.hidden) continue;
const st = h.states[eid];
list.push({
value: v,
label: reg.name || st?.attributes?.friendly_name || eid,
sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? 'группа' : 'хелпер'),
});
}
const f = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
const filtered = f
? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f))
: list;
filtered.sort((a, b) => a.label.localeCompare(b.label));
return filtered.slice(0, 200);
}
/** Список комнат всех пространств для виртуального устройства. */
private _allRoomsFlat(): { value: string; label: string }[] {
const res: { value: string; label: string }[] = [];
for (const sp of this._serverCfg?.spaces || []) {
for (const r of sp.rooms || []) {
if (!r.area) continue;
res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name });
}
}
return res;
}
private async _pickMarkerFiles(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const files = input.files ? [...input.files] : [];
if (!files.length || !this._markerDialog) return;
const mid = this._markerDialog.devId || 'new';
for (const file of files) {
try {
const buf = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 32768) bin += String.fromCharCode(...buf.subarray(i, i + 32768));
const resp = await this.hass.callWS({
type: 'houseplan/file/set', marker_id: mid, filename: file.name, data: btoa(bin),
});
this._markerDialog = {
...this._markerDialog!,
pdfs: [...this._markerDialog!.pdfs, { name: resp.name || file.name, url: resp.url }],
};
} catch (e: any) {
this._showToast('Файл не загружен: ' + (e?.message || e));
}
}
input.value = '';
}
private _removeMarkerPdf(url: string): void {
if (!this._markerDialog) return;
this._markerDialog = {
...this._markerDialog,
pdfs: this._markerDialog.pdfs.filter((p) => p.url !== url),
};
}
private async _saveMarker(): Promise<void> {
const dlg = this._markerDialog;
if (!dlg || dlg.busy) return;
if (dlg.binding === 'virtual' && !dlg.name.trim()) {
this._showToast('Укажите имя виртуального устройства');
return;
}
this._markerDialog = { ...dlg, busy: true };
try {
const cfg = this._serverCfg!;
cfg.markers = cfg.markers || [];
// определить id маркера
let id: string;
let space: string | null = null;
let area: string | null = null;
if (dlg.binding === 'virtual') {
const [sp, ar] = dlg.room ? dlg.room.split('#') : ['', ''];
space = sp || this._space;
area = ar || null;
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;
}
const oldId = dlg.devId;
const marker: Marker = {
id,
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
model: dlg.model.trim() || null,
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
pdfs: dlg.pdfs,
};
if (dlg.binding === 'virtual') {
marker.space = space;
marker.area = area;
}
// удалить прежний маркер (по старому id и по новому id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
cfg.markers.push(marker);
// позиция: если новый значок или сменилось пространство — поставить в центр комнаты/пространства
if (!this._layout[id]) {
const spm = this._spaceModel(space || undefined);
let cx = spm.vb[0] + spm.vb[2] / 2;
let cy = spm.vb[1] + spm.vb[3] / 2;
if (area) {
const room = spm.rooms.find((r) => r.area === area);
if (room) [cx, cy] = this._roomCenter(room);
}
this._layout[id] = this._normPos(space || this._space, cx, cy);
}
await this._saveConfigNow();
await this.hass.callWS({ type: 'houseplan/layout/set', layout: this._layout });
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast('Устройство сохранено');
} catch (e: any) {
this._markerDialog = { ...this._markerDialog!, busy: false };
this._showToast('Ошибка: ' + (e?.message || e));
}
}
private async _deleteMarker(): Promise<void> {
const dlg = this._markerDialog;
if (!dlg) return;
const d = dlg.devId ? this._devices.find((x) => x.id === dlg.devId) : null;
const label = dlg.name || 'устройство';
if (!confirm(`Убрать «${label}» с плана?`)) return;
const cfg = this._serverCfg!;
cfg.markers = cfg.markers || [];
if (d && d.bindingKind === 'virtual') {
cfg.markers = cfg.markers.filter((m) => m.id !== d.id);
} else if (d && d.marker) {
// был явный маркер → просто скрыть либо удалить: скрываем (авто вернётся если это авто-устройство)
cfg.markers = cfg.markers.filter((m) => m.id !== d.id);
if (d.bindingKind === 'device' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
} else if (d.bindingKind === 'entity' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true });
}
} else if (d && d.bindingKind === 'device' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
} else if (d && d.bindingKind === 'entity' && d.bindingRef) {
cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true });
}
try {
await this._saveConfigNow();
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast('Устройство убрано с плана');
} catch (e: any) {
this._showToast('Ошибка: ' + (e?.message || e));
}
}
private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } {
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1;
return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) };
}
// ================= УПРАВЛЕНИЕ ПРОСТРАНСТВАМИ =================
private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
@@ -1250,11 +1610,17 @@ class HouseplanCard extends LitElement {
</div>
<span class="count">${devs.length} устр.</span>
<span class="spacer"></span>
${this._norm
? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title="Добавить устройство на план">
<ha-icon icon="mdi:plus"></ha-icon>
</button>`
: nothing}
<button class="btn ${this._edit ? 'on' : ''}" @click=${() => {
this._edit = !this._edit;
this._markup = false;
this._selId = null;
}} title="Режим правки: перетаскивание иконок">
}} title="Режим правки: клик по значку — карточка, перетаскивание — двигать">
<ha-icon icon="mdi:cursor-move"></ha-icon>
</button>
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
@@ -1301,6 +1667,8 @@ class HouseplanCard extends LitElement {
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
@@ -1404,6 +1772,155 @@ class HouseplanCard extends LitElement {
</div>`;
}
private _renderInfoCard(): TemplateResult {
const d = this._infoCard!;
const st = d.primary ? this.hass.states[d.primary] : undefined;
const stateTxt = st ? this.hass.formatEntityState?.(st) ?? st.state : null;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
<div class="body">
${d.model ? html`<div class="inforow"><span class="k">Модель</span><span>${d.model}</span></div>` : nothing}
${stateTxt ? html`<div class="inforow"><span class="k">Состояние</span><span>${stateTxt}</span></div>` : nothing}
${d.link
? html`<div class="inforow"><span class="k">Ссылка</span>
<a href="${d.link}" target="_blank" rel="noreferrer noopener">${d.link}</a></div>`
: nothing}
${d.description ? html`<div class="infodesc">${d.description}</div>` : nothing}
${d.pdfs && d.pdfs.length
? html`<div class="inforow"><span class="k">Инструкции</span><span class="pdflist">
${d.pdfs.map(
(p) => html`<a class="pdf" href="${p.url}" target="_blank" rel="noreferrer noopener">
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>`
: nothing}
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length)
? html`<div class="infodesc muted">Нет дополнительной информации</div>`
: nothing}
</div>
<div class="row">
${d.primary
? html`<button class="btn" @click=${() => { const p = d.primary; this._infoCard = null; this._openMoreInfo(p); }}>
<ha-icon icon="mdi:open-in-new"></ha-icon>Открыть в HA
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._infoCard = null)}>Закрыть</button>
</div>
</div>
</div>`;
}
private _renderMarkerDialog(): TemplateResult {
const d = this._markerDialog!;
const isVirtual = d.binding === 'virtual';
const cands = this._bindingCandidates();
const curLabel = (() => {
if (isVirtual) return null;
const found = cands.find((c) => c.value === d.binding);
if (found) return found.label;
const [k, ref] = d.binding.split(':');
if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
return this.hass.states[ref]?.attributes?.friendly_name || ref;
})();
return html`<div class="menuwrap dialogwrap" @click=${() => (this._markerDialog = null)}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus"></ha-icon>
${d.devId ? 'Устройство на плане' : 'Новое устройство'}</div>
<div class="body">
<label>Имя (отображается на плане)</label>
<input class="namein" type="text" placeholder="Название"
.value=${d.name}
@input=${(e: Event) => (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} />
<label>Привязка к устройству HA</label>
<div class="bindsel ${isVirtual ? 'virt' : ''}">
<button class="opt ${isVirtual ? 'on' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: 'virtual' })}>
<ha-icon icon="mdi:map-marker-outline"></ha-icon>Виртуальное устройство (без привязки)
</button>
${!isVirtual
? html`<div class="curbind"><ha-icon icon="mdi:link-variant"></ha-icon>
<b>${curLabel}</b><span class="ref">${d.binding}</span></div>`
: nothing}
<input class="namein" type="text" placeholder="Поиск устройства / группы…"
.value=${d.bindingFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
<div class="candlist">
${cands.map(
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: c.value })}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
${!cands.length ? html`<div class="cand muted">ничего не найдено</div>` : nothing}
</div>
</div>
${isVirtual
? html`<label>Комната</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, room: (e.target as HTMLSelectElement).value })}>
<option value=""> выберите комнату </option>
${this._allRoomsFlat().map(
(r) => html`<option value=${r.value} ?selected=${r.value === d.room}>${r.label}</option>`,
)}
</select>`
: nothing}
<label>Иконка</label>
${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
@value-changed=${(e: any) => (this._markerDialog = { ...d, icon: e.detail.value || '' })}></ha-icon-picker>`
: html`<input class="namein" type="text" placeholder="mdi:… (пусто = авто)"
.value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
<label>Модель</label>
<input class="namein" type="text" placeholder="напр. Aqara T&amp;H"
.value=${d.model}
@input=${(e: Event) => (this._markerDialog = { ...d, model: (e.target as HTMLInputElement).value })} />
<label>Ссылка</label>
<input class="namein" type="url" placeholder="https://…"
.value=${d.link}
@input=${(e: Event) => (this._markerDialog = { ...d, link: (e.target as HTMLInputElement).value })} />
<label>Описание</label>
<textarea class="descin" rows="3" placeholder="Заметки, характеристики…"
.value=${d.description}
@input=${(e: Event) => (this._markerDialog = { ...d, description: (e.target as HTMLTextAreaElement).value })}></textarea>
<label>Инструкции (PDF и т.п.)</label>
<div class="pdfedit">
${d.pdfs.map(
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
<a href="${p.url}" target="_blank" rel="noreferrer noopener">${p.name}</a>
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
)}
<label class="btn filebtn">
<ha-icon icon="mdi:paperclip"></ha-icon>Прикрепить
<input type="file" hidden multiple accept=".pdf,.png,.jpg,.jpeg,.webp,.txt,application/pdf"
@change=${(e: Event) => this._pickMarkerFiles(e)} />
</label>
</div>
</div>
<div class="row">
${d.devId
? html`<button class="btn danger" @click=${this._deleteMarker}>
<ha-icon icon="mdi:delete-outline"></ha-icon>Убрать
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._markerDialog = null)}>Отмена</button>
<button class="btn on" @click=${this._saveMarker} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : 'Сохранить'}
</button>
</div>
</div>
</div>`;
}
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._spaceDialog = null)}>
@@ -1865,6 +2382,165 @@ class HouseplanCard extends LitElement {
.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;