mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
audit+refactor v1.7.0: drop hardcoded dacha bundle (293KB->83KB) + onboarding empty-state; remove migration & dead device_overrides/virtual_devices; extract src/logic.ts + validation.py (pure, unit-tested: 9 front + 10 back); fix latent bugs unmasked by truncated rules.ts (DOMAIN_PRIORITY, poly _defaultPositions); harden path sanitizers (leading-dot traversal); tsc strict in build+CI; sync versions
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,68 +0,0 @@
|
||||
/** Редактор конфигурации карточки (GUI в Lovelace). */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{
|
||||
name: 'default_floor',
|
||||
selector: {
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
options: [
|
||||
{ value: 'f1', label: '1 этаж' },
|
||||
{ value: 'f2', label: '2 этаж' },
|
||||
{ value: 'yard', label: 'Двор' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
{ name: 'show_temperature', selector: { boolean: {} } },
|
||||
{ name: 'live_states', selector: { boolean: {} } },
|
||||
{ name: 'show_signal', selector: { boolean: {} } },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
title: 'Заголовок',
|
||||
default_floor: 'Этаж по умолчанию',
|
||||
icon_size: 'Размер иконок, % ширины плана',
|
||||
show_temperature: 'Показывать температуру',
|
||||
live_states: 'Живые состояния (вкл/выкл, открыто…)',
|
||||
show_signal: 'Показывать сигнал zigbee (LQI)',
|
||||
};
|
||||
|
||||
class HouseplanCardEditor extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: any;
|
||||
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
public setConfig(config: any): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) return nothing;
|
||||
return html`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${(s: any) => LABELS[s.name] || s.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const config = { ...this._config, ...ev.detail.value };
|
||||
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
|
||||
e.detail = { config };
|
||||
this.dispatchEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('houseplan-card-editor')) {
|
||||
customElements.define('houseplan-card-editor', HouseplanCardEditor);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Auto-generated from prototype data.js — geometry of house plan (1 unit = 1 px of 1489x1053 render)
|
||||
export const IMG_W = 1489;
|
||||
export const IMG_H = 1053;
|
||||
export interface Room { floor: string; name: string; area: string; x: number; y: number; w: number; h: number; }
|
||||
export const ROOMS: Room[] = [
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостиная",
|
||||
"area": "gostinaia_na_dache",
|
||||
"x": 518,
|
||||
"y": 226,
|
||||
"w": 420,
|
||||
"h": 292
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевой с/у",
|
||||
"area": "gostevoi_s_u",
|
||||
"x": 615,
|
||||
"y": 524,
|
||||
"w": 162,
|
||||
"h": 156
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Котельная",
|
||||
"area": "kotelnaia",
|
||||
"x": 614,
|
||||
"y": 686,
|
||||
"w": 163,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Кладовка уличная",
|
||||
"area": "kladovka",
|
||||
"x": 518,
|
||||
"y": 686,
|
||||
"w": 82,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Прихожая",
|
||||
"area": "prikhozhaia",
|
||||
"x": 784,
|
||||
"y": 600,
|
||||
"w": 152,
|
||||
"h": 255
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевая спальня",
|
||||
"area": "gostevaia_komnata",
|
||||
"x": 949,
|
||||
"y": 611,
|
||||
"w": 201,
|
||||
"h": 245
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Майя",
|
||||
"area": "detskaia_maiia",
|
||||
"x": 389,
|
||||
"y": 58,
|
||||
"w": 424,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Элина",
|
||||
"area": "detskaia_elina",
|
||||
"x": 830,
|
||||
"y": 58,
|
||||
"w": 426,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детский с/у",
|
||||
"area": "detskii_s_u",
|
||||
"x": 389,
|
||||
"y": 360,
|
||||
"w": 264,
|
||||
"h": 168
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер с/у",
|
||||
"area": "master_s_u",
|
||||
"x": 389,
|
||||
"y": 545,
|
||||
"w": 264,
|
||||
"h": 155
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер-спальня",
|
||||
"area": "spalnia",
|
||||
"x": 657,
|
||||
"y": 607,
|
||||
"w": 320,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Кабинет",
|
||||
"area": "kabinet",
|
||||
"x": 984,
|
||||
"y": 607,
|
||||
"w": 274,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"floor": "yard",
|
||||
"name": "Двор / Участок",
|
||||
"area": "dvor",
|
||||
"x": 380,
|
||||
"y": 120,
|
||||
"w": 640,
|
||||
"h": 760
|
||||
}
|
||||
];
|
||||
export const FLOOR_VB: Record<string, number[]> = {"f1": [480, 30, 760, 950], "f2": [350, 20, 1040, 960], "yard": [340, 90, 720, 800]};
|
||||
export const FLOOR_TITLES: Record<string, string> = {f1: '1 этаж', f2: '2 этаж', yard: 'Двор'};
|
||||
export const AREA_NAMES: Record<string, string> = {
|
||||
"gostinaia_na_dache": "Гостиная",
|
||||
"gostevoi_s_u": "Гостевой с/у",
|
||||
"kotelnaia": "Котельная",
|
||||
"kladovka": "Кладовка уличная",
|
||||
"prikhozhaia": "Прихожая",
|
||||
"gostevaia_komnata": "Гостевая спальня",
|
||||
"detskaia_maiia": "Детская Майя",
|
||||
"detskaia_elina": "Детская Элина",
|
||||
"detskii_s_u": "Детский с/у",
|
||||
"master_s_u": "Мастер с/у",
|
||||
"spalnia": "Мастер-спальня",
|
||||
"kabinet": "Кабинет",
|
||||
"dvor": "Двор / Участок"
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Правила прототипа — перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.2–4.5).
|
||||
*/
|
||||
|
||||
/** Интеграции-домены, чьи устройства скрываются (курирование). */
|
||||
export const EXCLUDED_DOMAINS = new Set([
|
||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||
'upnp_serial_number',
|
||||
]);
|
||||
|
||||
/** Заголовки групп ламп по area_id. */
|
||||
export const GROUP_TITLES: Record<string, string> = {
|
||||
gostinaia_na_dache: 'Настенные лампы',
|
||||
prikhozhaia: 'Свет в прихожей',
|
||||
detskaia_maiia: 'Свет у Майи',
|
||||
detskaia_elina: 'Свет у Элины',
|
||||
};
|
||||
|
||||
const ICON_RULES: Array<[string, string]> = [
|
||||
['протечк', 'mdi:water-alert'],
|
||||
['клапан', 'mdi:pipe-valve'],
|
||||
['дым', 'mdi:smoke-detector'],
|
||||
['термоголов', 'mdi:radiator'],
|
||||
['температ', 'mdi:thermometer'],
|
||||
['qingping|air monitor|молекул', 'mdi:air-filter'],
|
||||
['штор', 'mdi:roller-shade'],
|
||||
['розетк|plug', 'mdi:power-socket-de'],
|
||||
['выключат|switch', 'mdi:light-switch'],
|
||||
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'],
|
||||
['камер|camera', 'mdi:cctv'],
|
||||
['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'],
|
||||
['ворота|garage', 'mdi:garage-variant'],
|
||||
['калитк|door|открыт', 'mdi:door'],
|
||||
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'],
|
||||
['вводный автомат|breaker|wifimcbn', 'mdi:electric-switch'],
|
||||
['myheat|котёл|котел|boiler|отоплен', 'mdi:water-boiler'],
|
||||
['холодильник|fridge', 'mdi:fridge'],
|
||||
['стиральн|washer', 'mdi:washing-machine'],
|
||||
['сушилк|dryer', 'mdi:tumble-dryer'],
|
||||
['пылесос|vacuum|dreame', 'mdi:robot-vacuum'],
|
||||
['soundbar|колонк|станц', 'mdi:soundbar'],
|
||||
['tv|телевизор|hyundaitv|mitv', 'mdi:television'],
|
||||
['keenetic|роутер|router', 'mdi:router-wireless'],
|
||||
['ибп|ups|kirpich', 'mdi:battery-charging-high'],
|
||||
['slzb|координат|zigbee', 'mdi:zigbee'],
|
||||
];
|
||||
|
||||
/** Подбор MDI-иконки по имени/модели устройства. */
|
||||
export function iconFor(name?: string, model?: string): string {
|
||||
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
|
||||
for (const [pat, icon] of ICON_RULES) {
|
||||
if (new RegExp(pat).test(s)) return icon;
|
||||
}
|
||||
return 'mdi:chip';
|
||||
}
|
||||
|
||||
/** Приоритет доменов для выбора «первичной» сущности устройства (more-info). */
|
||||
export const DOMAIN_PRIORITY = [
|
||||
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
|
||||
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
|
||||
'alarm_control_panel', 'sensor', 'binary_sensor', 'event', 'button',
|
||||
'number', 'select',
|
||||
+91
-186
@@ -7,21 +7,16 @@
|
||||
* Раскладка иконок хранится на сервере (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { ROOMS, FLOOR_VB, FLOOR_TITLES, Room } from './data/house';
|
||||
import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
||||
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
} from './logic';
|
||||
import './editor';
|
||||
|
||||
const CARD_VERSION = '1.6.2';
|
||||
const CARD_VERSION = '1.7.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||
|
||||
// Натуральные размеры legacy-планов (для миграции): SVG viewBox РЕМПЛАННЕР
|
||||
const LEGACY_PLAN_SIZE: Record<string, [number, number]> = {
|
||||
f1: [1196.6656, 1467.26],
|
||||
f2: [1170.0986, 1073],
|
||||
};
|
||||
|
||||
interface RoomCfg {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -44,17 +39,6 @@ interface SpaceModel {
|
||||
rooms: RoomCfg[]; // render units
|
||||
}
|
||||
|
||||
interface VirtualDevice {
|
||||
id: string;
|
||||
space: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
x: number; // normalized
|
||||
y: number;
|
||||
note?: string | null;
|
||||
entity_id?: string | null;
|
||||
}
|
||||
|
||||
interface PdfRef {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -77,8 +61,6 @@ interface Marker {
|
||||
|
||||
interface ServerConfig {
|
||||
spaces: any[];
|
||||
device_overrides: Record<string, { hidden?: boolean; icon?: string | null; name?: string | null }>;
|
||||
virtual_devices: VirtualDevice[];
|
||||
markers: Marker[];
|
||||
settings: { exclude_integrations?: string[]; group_lights?: boolean };
|
||||
}
|
||||
@@ -93,7 +75,7 @@ interface DevItem {
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
temp?: number | null;
|
||||
virtual?: VirtualDevice;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // связанный маркер конфига (метаданные, оверрайды)
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
bindingRef?: string; // device_id / entity_id
|
||||
@@ -110,15 +92,8 @@ interface CardConfig {
|
||||
show_temperature?: boolean;
|
||||
live_states?: boolean;
|
||||
show_signal?: boolean;
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
/** Цвет LQI: ≤40 — красный, ≥180 — зелёный, между — градиент. */
|
||||
const lqiColor = (lqi: number): string => {
|
||||
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
};
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||
ev.detail = detail ?? {};
|
||||
@@ -157,7 +132,6 @@ class HouseplanCard extends LitElement {
|
||||
private _selId: string | null = null;
|
||||
private _toast = '';
|
||||
private _toastTimer?: number;
|
||||
private _migrating = false;
|
||||
|
||||
// --- редактор разметки комнат ---
|
||||
private _markup = false;
|
||||
@@ -205,7 +179,6 @@ class HouseplanCard extends LitElement {
|
||||
_selId: { state: true },
|
||||
_toast: { state: true },
|
||||
_serverCfg: { state: true },
|
||||
_migrating: { state: true },
|
||||
_markup: { state: true },
|
||||
_tool: { state: true },
|
||||
_path: { state: true },
|
||||
@@ -294,49 +267,34 @@ class HouseplanCard extends LitElement {
|
||||
return 12;
|
||||
}
|
||||
|
||||
// ================= РЕЗОЛВ МОДЕЛИ (сервер или legacy) =================
|
||||
// ================= РЕЗОЛВ МОДЕЛИ (серверная конфигурация) =================
|
||||
|
||||
/** Есть ли серверная конфигурация с пространствами (иначе — онбординг). */
|
||||
private get _norm(): boolean {
|
||||
return !!(this._serverCfg && this._serverCfg.spaces.length);
|
||||
}
|
||||
|
||||
/** Пространства в рендер-единицах: сервер → NORM_W×NORM_W/aspect; legacy → холст 1489×1053. */
|
||||
/** Пространства в рендер-единицах (NORM_W × NORM_W/aspect). */
|
||||
private get _model(): SpaceModel[] {
|
||||
if (this._norm) {
|
||||
return this._serverCfg!.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const scale = (r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const scale = (r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
}
|
||||
// legacy: вшитые данные
|
||||
const legacyRooms = this._config?.rooms?.length ? this._config.rooms : ROOMS;
|
||||
return Object.keys(FLOOR_VB).map((fl) => {
|
||||
const rect = FLOOR_BG_RECT[fl];
|
||||
const bgHref = FLOOR_BG[fl];
|
||||
return {
|
||||
id: fl,
|
||||
title: FLOOR_TITLES[fl] || fl,
|
||||
vb: FLOOR_VB[fl],
|
||||
bg: bgHref && rect ? { href: bgHref, x: rect[0], y: rect[1], w: rect[2], h: rect[3] } : null,
|
||||
rooms: legacyRooms
|
||||
.filter((r) => r.floor === fl)
|
||||
.map((r) => ({ name: r.name, area: r.area || null, x: r.x, y: r.y, w: r.w, h: r.h })),
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -596,7 +554,6 @@ class HouseplanCard extends LitElement {
|
||||
private _buildDevices(): DevItem[] {
|
||||
const h = this.hass;
|
||||
const areaMap = this._areaToSpace;
|
||||
const overrides = this._serverCfg?.device_overrides || {};
|
||||
const groupLights = this._settings.group_lights !== false;
|
||||
const groupedAreas = this._groupedLightAreas();
|
||||
const excluded = this._excluded;
|
||||
@@ -611,9 +568,8 @@ class HouseplanCard extends LitElement {
|
||||
if (!area || !areaMap[area]) continue;
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже
|
||||
const ov = overrides[dev.id] || {};
|
||||
const marker = this._markerFor('device', dev.id);
|
||||
if ((marker && marker.hidden) || ov.hidden) continue;
|
||||
if (marker && marker.hidden) continue;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = this._domainOfDevice(dev, entIds);
|
||||
if (excluded.has(dom)) continue;
|
||||
@@ -621,13 +577,12 @@ class HouseplanCard extends LitElement {
|
||||
if (/scene/i.test(dev.model || '')) continue;
|
||||
if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue;
|
||||
if (dom === 'myheat' && dev.via_device_id) continue;
|
||||
const name = (ov.name || dev.name_by_user || dev.name || 'без имени').trim();
|
||||
const name = (dev.name_by_user || dev.name || 'без имени').trim();
|
||||
const key = name + '|' + area;
|
||||
if (seen[key]) continue;
|
||||
seen[key] = 1;
|
||||
let icon = iconFor(name, dev.model);
|
||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||
if (ov.icon) icon = ov.icon;
|
||||
if (groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
|
||||
const item: DevItem = {
|
||||
id: dev.id,
|
||||
@@ -723,7 +678,7 @@ class HouseplanCard extends LitElement {
|
||||
icon: m.icon || 'mdi:map-marker',
|
||||
entities: [],
|
||||
bindingKind: 'virtual',
|
||||
virtual: { id: m.id, space, name: m.name || '', icon: m.icon || 'mdi:map-marker', x: 0, y: 0 },
|
||||
virtual: true,
|
||||
};
|
||||
this._applyMarker(item, m);
|
||||
rest.push(item);
|
||||
@@ -734,6 +689,18 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
// ================= позиции =================
|
||||
|
||||
/** Ограничивающий прямоугольник комнаты (rect или полигон) в рендер-единицах. */
|
||||
private _roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
const xs = r.poly.map((p) => p[0]);
|
||||
const ys = r.poly.map((p) => p[1]);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
|
||||
}
|
||||
|
||||
private _defaultPositions(): Record<string, { x: number; y: number }> {
|
||||
const map: Record<string, { x: number; y: number }> = {};
|
||||
for (const s of this._model) {
|
||||
@@ -741,9 +708,10 @@ class HouseplanCard extends LitElement {
|
||||
if (!r.area) continue;
|
||||
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id);
|
||||
if (!ds.length) continue;
|
||||
const pad = Math.min(r.w, r.h) * 0.12;
|
||||
const iw = r.w - pad * 2;
|
||||
const ih = r.h - pad * 2;
|
||||
const b = this._roomBounds(r);
|
||||
const pad = Math.min(b.w, b.h) * 0.12;
|
||||
const iw = b.w - pad * 2;
|
||||
const ih = b.h - pad * 2;
|
||||
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
|
||||
const rows = Math.ceil(ds.length / cols);
|
||||
const cw = iw / cols;
|
||||
@@ -751,7 +719,7 @@ class HouseplanCard extends LitElement {
|
||||
ds.forEach((d, i) => {
|
||||
const c = i % cols;
|
||||
const rw = Math.floor(i / cols);
|
||||
map[d.id] = { x: r.x + pad + cw * (c + 0.5), y: r.y + pad + ch * (rw + 0.5) };
|
||||
map[d.id] = { x: b.x + pad + cw * (c + 0.5), y: b.y + pad + ch * (rw + 0.5) };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -956,16 +924,15 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _snap(p: number[]): number[] {
|
||||
const g = this._gridPitch;
|
||||
return [Math.round(p[0] / g) * g, Math.round(p[1] / g) * g];
|
||||
return [snapToGrid(p[0], g), snapToGrid(p[1], g)];
|
||||
}
|
||||
|
||||
private _samePt(a: number[], b: number[]): boolean {
|
||||
return Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
|
||||
return samePoint(a, b);
|
||||
}
|
||||
|
||||
private _segKey(a: number[], b: number[]): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
return segKeyOf(a, b);
|
||||
}
|
||||
|
||||
private _saveConfig = debounce(() => {
|
||||
@@ -1014,16 +981,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pointInRoom(p: number[], r: RoomCfg): boolean {
|
||||
if (r.poly) {
|
||||
let inside = false;
|
||||
const poly = r.poly;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i];
|
||||
const [xj, yj] = poly[j];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
if (r.poly) return pointInPolygon(p, r.poly);
|
||||
return (
|
||||
r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h!
|
||||
);
|
||||
@@ -1285,13 +1243,8 @@ class HouseplanCard extends LitElement {
|
||||
const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', ''];
|
||||
let space: string | null = roomSp || null;
|
||||
let area: string | null = roomAr || null;
|
||||
if (dlg.binding === 'virtual') {
|
||||
if (!space) space = this._space;
|
||||
id = dlg.devId && dlg.devId.startsWith('v_') ? dlg.devId : 'v_' + Date.now().toString(36);
|
||||
} else {
|
||||
const [kind, ref] = dlg.binding.split(':');
|
||||
id = kind === 'device' ? ref : 'lg_' + ref;
|
||||
}
|
||||
if (dlg.binding === 'virtual' && !space) space = this._space;
|
||||
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
|
||||
const oldId = dlg.devId;
|
||||
const marker: Marker = {
|
||||
id,
|
||||
@@ -1379,8 +1332,8 @@ class HouseplanCard extends LitElement {
|
||||
// ================= УПРАВЛЕНИЕ ПРОСТРАНСТВАМИ =================
|
||||
|
||||
private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
|
||||
if (!this._norm) {
|
||||
this._showToast('Управление пространствами доступно после переноса конфига на сервер');
|
||||
if (!this._serverStorage || !this._serverCfg) {
|
||||
this._showToast('Интеграция House Plan не установлена — управление недоступно');
|
||||
return;
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
@@ -1488,90 +1441,31 @@ class HouseplanCard extends LitElement {
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
}
|
||||
|
||||
// ================= МИГРАЦИЯ legacy → сервер =================
|
||||
|
||||
private async _migrateToServer(): Promise<void> {
|
||||
if (!this._serverStorage || this._norm || this._migrating) return;
|
||||
if (!confirm('Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?')) return;
|
||||
this._migrating = true;
|
||||
try {
|
||||
const spaces: any[] = [];
|
||||
for (const fl of Object.keys(FLOOR_VB)) {
|
||||
const vb = FLOOR_VB[fl];
|
||||
const rect = FLOOR_BG_RECT[fl] || vb; // yard: нормируем относительно viewBox
|
||||
const [rx, ry, rw, rh] = rect;
|
||||
let planUrl: string | null = null;
|
||||
let aspect = rw / rh;
|
||||
const bg = FLOOR_BG[fl];
|
||||
if (bg && FLOOR_BG_RECT[fl]) {
|
||||
const nat = LEGACY_PLAN_SIZE[fl];
|
||||
if (nat) aspect = nat[0] / nat[1];
|
||||
const b64 = bg.split(',')[1];
|
||||
const resp = await this.hass.callWS({
|
||||
type: 'houseplan/plan/set',
|
||||
space_id: fl,
|
||||
ext: 'svg',
|
||||
data: b64,
|
||||
});
|
||||
planUrl = resp.url;
|
||||
}
|
||||
const rooms = ROOMS.filter((r) => r.floor === fl).map((r, i) => ({
|
||||
id: fl + '_r' + i,
|
||||
name: r.name,
|
||||
area: r.area || null,
|
||||
x: (r.x - rx) / rw,
|
||||
y: (r.y - ry) / rh,
|
||||
w: r.w / rw,
|
||||
h: r.h / rh,
|
||||
}));
|
||||
spaces.push({
|
||||
id: fl,
|
||||
title: FLOOR_TITLES[fl] || fl,
|
||||
plan_url: planUrl,
|
||||
aspect,
|
||||
view_box: [(vb[0] - rx) / rw, (vb[1] - ry) / rh, vb[2] / rw, vb[3] / rh],
|
||||
rooms,
|
||||
});
|
||||
}
|
||||
const config = {
|
||||
spaces,
|
||||
device_overrides: {},
|
||||
virtual_devices: [],
|
||||
settings: { exclude_integrations: [...EXCLUDED_DOMAINS], group_lights: true },
|
||||
};
|
||||
// раскладка: канвас → нормированные координаты пространства
|
||||
const layout: Record<string, any> = {};
|
||||
for (const d of this._devices) {
|
||||
if (d.virtual) continue;
|
||||
const saved = this._layout[d.id] || this._defPos[d.id];
|
||||
if (!saved) continue;
|
||||
const rect = FLOOR_BG_RECT[d.space] || FLOOR_VB[d.space];
|
||||
layout[d.id] = {
|
||||
s: d.space,
|
||||
x: (saved.x - rect[0]) / rect[2],
|
||||
y: (saved.y - rect[1]) / rect[3],
|
||||
};
|
||||
}
|
||||
await this.hass.callWS({ type: 'houseplan/config/set', config });
|
||||
await this.hass.callWS({ type: 'houseplan/layout/set', layout });
|
||||
this._serverCfg = config as any;
|
||||
this._layout = layout;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this._showToast('Конфигурация перенесена на сервер — карта работает от server config');
|
||||
} catch (e: any) {
|
||||
this._showToast('Ошибка миграции: ' + (e?.message || e));
|
||||
} finally {
|
||||
this._migrating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ================= рендер =================
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const model = this._model;
|
||||
if (!model.length) return html`<ha-card><div class="empty">Нет настроенных пространств</div></ha-card>`;
|
||||
if (!model.length) {
|
||||
return html`<ha-card>
|
||||
<div class="head">
|
||||
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || 'План дома'}</div>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<ha-icon icon="mdi:floor-plan" class="big"></ha-icon>
|
||||
<p>Пространств пока нет.</p>
|
||||
${this._serverStorage
|
||||
? html`<p class="muted">Добавьте первое пространство и загрузите план этажа.</p>
|
||||
<button class="btn on" @click=${() => this._openSpaceDialog('create')}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>Добавить пространство
|
||||
</button>`
|
||||
: html`<p class="muted">Установите интеграцию House Plan и добавьте запись в «Устройства и службы».</p>`}
|
||||
</div>
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
|
||||
</ha-card>`;
|
||||
}
|
||||
const space = this._spaceModel();
|
||||
const vb = space.vb;
|
||||
const devs = this._devices.filter((d) => d.space === space.id);
|
||||
@@ -2021,12 +1915,6 @@ class HouseplanCard extends LitElement {
|
||||
: 'раскладка: сервер · конфиг: встроенный'
|
||||
: 'сохранение: этот браузер'}
|
||||
</span>
|
||||
${this._serverStorage && !this._norm
|
||||
? html`<button class="btn" ?disabled=${this._migrating} @click=${this._migrateToServer}
|
||||
title="Перенести планы, комнаты и раскладку в хранилище HA">
|
||||
<ha-icon icon="mdi:cloud-upload"></ha-icon>${this._migrating ? '…' : 'На сервер'}
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
|
||||
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
||||
</button>
|
||||
@@ -2047,9 +1935,26 @@ class HouseplanCard extends LitElement {
|
||||
overflow: visible; /* overflow:hidden ломает position:sticky у шапки */
|
||||
}
|
||||
.empty {
|
||||
padding: 32px;
|
||||
color: var(--hp-muted);
|
||||
padding: 40px 24px;
|
||||
color: var(--hp-txt);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .big {
|
||||
--mdc-icon-size: 56px;
|
||||
color: var(--hp-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.empty .muted {
|
||||
color: var(--hp-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.empty .btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.hdr {
|
||||
position: sticky;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Чистые функции без зависимостей от Lit/DOM — легко покрываются юнит-тестами.
|
||||
*/
|
||||
|
||||
/** Цвет LQI zigbee: ≤40 — красный, ≥180 — зелёный, между — hsl-градиент. */
|
||||
export function lqiColor(lqi: number): string {
|
||||
const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
|
||||
return `hsl(${Math.round(hue)}, 85%, 55%)`;
|
||||
}
|
||||
|
||||
/** Привязать координату к ближайшему узлу сетки с шагом pitch. */
|
||||
export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
}
|
||||
|
||||
/** Канонический ключ отрезка (независим от направления). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
}
|
||||
|
||||
/** Совпадение точек с допуском. */
|
||||
export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
|
||||
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
|
||||
}
|
||||
|
||||
/** Точка внутри полигона (ray casting). */
|
||||
export function pointInPolygon(p: number[], poly: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i];
|
||||
const [xj, yj] = poly[j];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* id маркера по привязке: device → device_id, entity → 'lg_'+entity_id,
|
||||
* virtual → переданный existing (если это уже v_-маркер) либо новый через newId().
|
||||
*/
|
||||
export function markerIdForBinding(
|
||||
binding: string,
|
||||
existingId: string | undefined,
|
||||
newId: () => string,
|
||||
): string {
|
||||
const [kind, ref] = binding.split(':');
|
||||
if (kind === 'device') return ref;
|
||||
if (kind === 'entity') return 'lg_' + ref;
|
||||
return existingId && existingId.startsWith('v_') ? existingId : newId();
|
||||
}
|
||||
|
||||
/** Средний LQI по набору значений (или null). */
|
||||
export function averageLqi(values: number[]): number | null {
|
||||
if (!values.length) return null;
|
||||
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
|
||||
}
|
||||
+2
-1
@@ -60,4 +60,5 @@ export const DOMAIN_PRIORITY = [
|
||||
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
|
||||
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
|
||||
'alarm_control_panel', 'sensor', 'binary_sensor', 'event', 'button',
|
||||
'number', 'select',
|
||||
'number', 'select', 'update',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user