mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
+275
@@ -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
@@ -1,30 +1,9 @@
|
||||
/** Редактор конфигурации карточки (GUI в Lovelace). */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{
|
||||
name: 'default_floor',
|
||||
selector: {
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
options: [
|
||||
{ value: 'f1', label: '1 этаж' },
|
||||
{ value: 'f2', label: '2 этаж' },
|
||||
{ value: 'yard', label: 'Двор' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
{ name: 'show_temperature', selector: { boolean: {} } },
|
||||
{ name: 'live_states', selector: { boolean: {} } },
|
||||
{ name: 'show_signal', selector: { boolean: {} } },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
title: 'Заголовок',
|
||||
default_floor: 'Этаж по умолчанию',
|
||||
default_floor: 'Пространство по умолчанию',
|
||||
icon_size: 'Размер иконок, % ширины плана',
|
||||
show_temperature: 'Показывать температуру',
|
||||
live_states: 'Живые состояния (вкл/выкл, открыто…)',
|
||||
@@ -34,22 +13,60 @@ const LABELS: Record<string, string> = {
|
||||
class HouseplanCardEditor extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: any;
|
||||
private _spaces: { value: string; label: string }[] | null = null;
|
||||
private _spacesLoading = false;
|
||||
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_spaces: { state: true },
|
||||
};
|
||||
|
||||
public setConfig(config: any): void {
|
||||
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() {
|
||||
if (!this.hass || !this._config) return nothing;
|
||||
this._loadSpaces();
|
||||
return html`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${(s: any) => LABELS[s.name] || s.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;
|
||||
|
||||
+66
-1103
File diff suppressed because it is too large
Load Diff
@@ -98,3 +98,17 @@ export function declump(
|
||||
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
@@ -9,48 +9,40 @@ export const EXCLUDED_DOMAINS = new Set([
|
||||
'upnp_serial_number',
|
||||
]);
|
||||
|
||||
/** Заголовки групп ламп по area_id. */
|
||||
export const GROUP_TITLES: Record<string, string> = {
|
||||
gostinaia_na_dache: 'Настенные лампы',
|
||||
prikhozhaia: 'Свет в прихожей',
|
||||
detskaia_maiia: 'Свет у Майи',
|
||||
detskaia_elina: 'Свет у Элины',
|
||||
};
|
||||
|
||||
const ICON_RULES: Array<[string, string]> = [
|
||||
['протечк', 'mdi:water-alert'],
|
||||
['клапан', 'mdi:pipe-valve'],
|
||||
['дым', 'mdi:smoke-detector'],
|
||||
['термоголов', 'mdi:radiator'],
|
||||
['температ', 'mdi:thermometer'],
|
||||
['qingping|air monitor|молекул', 'mdi:air-filter'],
|
||||
['штор', 'mdi:roller-shade'],
|
||||
['розетк|plug', 'mdi:power-socket-de'],
|
||||
['выключат|switch', 'mdi:light-switch'],
|
||||
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'],
|
||||
['камер|camera', 'mdi:cctv'],
|
||||
['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'],
|
||||
['ворота|garage', 'mdi:garage-variant'],
|
||||
['калитк|door|открыт', 'mdi:door'],
|
||||
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'],
|
||||
['вводный автомат|breaker|wifimcbn', 'mdi:electric-switch'],
|
||||
['myheat|котёл|котел|boiler|отоплен', 'mdi:water-boiler'],
|
||||
['холодильник|fridge', 'mdi:fridge'],
|
||||
['стиральн|washer', 'mdi:washing-machine'],
|
||||
['сушилк|dryer', 'mdi:tumble-dryer'],
|
||||
['пылесос|vacuum|dreame', 'mdi:robot-vacuum'],
|
||||
['soundbar|колонк|станц', 'mdi:soundbar'],
|
||||
['tv|телевизор|hyundaitv|mitv', 'mdi:television'],
|
||||
['keenetic|роутер|router', 'mdi:router-wireless'],
|
||||
['ибп|ups|kirpich', 'mdi:battery-charging-high'],
|
||||
['slzb|координат|zigbee', 'mdi:zigbee'],
|
||||
const ICON_RULES: Array<[RegExp, string]> = [
|
||||
[/протечк/, 'mdi:water-alert'],
|
||||
[/клапан/, 'mdi:pipe-valve'],
|
||||
[/дым/, 'mdi:smoke-detector'],
|
||||
[/термоголов/, 'mdi:radiator'],
|
||||
[/температ/, 'mdi:thermometer'],
|
||||
[/qingping|air monitor|молекул/, 'mdi:air-filter'],
|
||||
[/штор/, 'mdi:roller-shade'],
|
||||
[/розетк|plug/, 'mdi:power-socket-de'],
|
||||
[/выключат|switch/, 'mdi:light-switch'],
|
||||
[/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
|
||||
[/камер|camera/, 'mdi:cctv'],
|
||||
[/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
|
||||
[/ворота|garage/, 'mdi:garage-variant'],
|
||||
[/калитк|door|открыт/, 'mdi:door'],
|
||||
[/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
|
||||
[/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
|
||||
[/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
|
||||
[/холодильник|fridge/, 'mdi:fridge'],
|
||||
[/стиральн|washer/, 'mdi:washing-machine'],
|
||||
[/сушилк|dryer/, 'mdi:tumble-dryer'],
|
||||
[/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
|
||||
[/soundbar|колонк|станц/, 'mdi:soundbar'],
|
||||
[/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
|
||||
[/keenetic|роутер|router/, 'mdi:router-wireless'],
|
||||
[/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
|
||||
[/slzb|координат|zigbee/, 'mdi:zigbee'],
|
||||
];
|
||||
|
||||
/** Подбор MDI-иконки по имени/модели устройства. */
|
||||
export function iconFor(name?: string, model?: string): string {
|
||||
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
|
||||
for (const [pat, icon] of ICON_RULES) {
|
||||
if (new RegExp(pat).test(s)) return icon;
|
||||
if (pat.test(s)) return icon;
|
||||
}
|
||||
return 'mdi:chip';
|
||||
}
|
||||
|
||||
+727
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user