mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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}")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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}
|
||||
)
|
||||
|
||||
Vendored
+324
-56
File diff suppressed because one or more lines are too long
@@ -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}` |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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&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;
|
||||
|
||||
Reference in New Issue
Block a user