feat v1.2.0: room borders snapped to walls; zigbee LQI under icons + room average in tooltip with red-green gradient; lock icon for devices with lock.* entity (TTLock); show_signal option

This commit is contained in:
JB
2026-07-03 23:31:25 +03:00
parent 7f9bbc1226
commit 4e0bcbe749
9 changed files with 289 additions and 68 deletions
File diff suppressed because one or more lines are too long
+21 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.1.0", "version": "1.2.0",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", "description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+68
View File
@@ -0,0 +1,68 @@
/** Редактор конфигурации карточки (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);
}
+46 -46
View File
@@ -7,109 +7,109 @@ export const ROOMS: Room[] = [
"floor": "f1", "floor": "f1",
"name": "Гостиная", "name": "Гостиная",
"area": "gostinaia_na_dache", "area": "gostinaia_na_dache",
"x": 510, "x": 518,
"y": 205, "y": 226,
"w": 430, "w": 420,
"h": 310 "h": 292
}, },
{ {
"floor": "f1", "floor": "f1",
"name": "Гостевой с/у", "name": "Гостевой с/у",
"area": "gostevoi_s_u", "area": "gostevoi_s_u",
"x": 600, "x": 615,
"y": 515, "y": 524,
"w": 190, "w": 162,
"h": 170 "h": 156
}, },
{ {
"floor": "f1", "floor": "f1",
"name": "Котельная", "name": "Котельная",
"area": "kotelnaia", "area": "kotelnaia",
"x": 600, "x": 614,
"y": 690, "y": 686,
"w": 190, "w": 163,
"h": 180 "h": 169
}, },
{ {
"floor": "f1", "floor": "f1",
"name": "Кладовка уличная", "name": "Кладовка уличная",
"area": "kladovka", "area": "kladovka",
"x": 510, "x": 518,
"y": 690, "y": 686,
"w": 90, "w": 82,
"h": 180 "h": 169
}, },
{ {
"floor": "f1", "floor": "f1",
"name": "Прихожая", "name": "Прихожая",
"area": "prikhozhaia", "area": "prikhozhaia",
"x": 790, "x": 784,
"y": 600, "y": 600,
"w": 150, "w": 152,
"h": 270 "h": 255
}, },
{ {
"floor": "f1", "floor": "f1",
"name": "Гостевая спальня", "name": "Гостевая спальня",
"area": "gostevaia_komnata", "area": "gostevaia_komnata",
"x": 945, "x": 949,
"y": 600, "y": 611,
"w": 255, "w": 201,
"h": 270 "h": 245
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Детская Майя", "name": "Детская Майя",
"area": "detskaia_maiia", "area": "detskaia_maiia",
"x": 365, "x": 389,
"y": 40, "y": 58,
"w": 435, "w": 424,
"h": 315 "h": 297
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Детская Элина", "name": "Детская Элина",
"area": "detskaia_elina", "area": "detskaia_elina",
"x": 810, "x": 830,
"y": 40, "y": 58,
"w": 550, "w": 426,
"h": 315 "h": 297
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Детский с/у", "name": "Детский с/у",
"area": "detskii_s_u", "area": "detskii_s_u",
"x": 390, "x": 389,
"y": 360, "y": 360,
"w": 260, "w": 264,
"h": 180 "h": 168
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Мастер с/у", "name": "Мастер с/у",
"area": "master_s_u", "area": "master_s_u",
"x": 390, "x": 389,
"y": 540, "y": 545,
"w": 260, "w": 264,
"h": 160 "h": 155
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Мастер-спальня", "name": "Мастер-спальня",
"area": "spalnia", "area": "spalnia",
"x": 650, "x": 657,
"y": 600, "y": 607,
"w": 330, "w": 320,
"h": 320 "h": 311
}, },
{ {
"floor": "f2", "floor": "f2",
"name": "Кабинет", "name": "Кабинет",
"area": "kabinet", "area": "kabinet",
"x": 985, "x": 984,
"y": 600, "y": 607,
"w": 290, "w": 274,
"h": 320 "h": 311
}, },
{ {
"floor": "yard", "floor": "yard",
+63
View File
@@ -0,0 +1,63 @@
/**
* Правила прототипа — перенесены 1-в-1 из index.html/build_data.py (см. DOCUMENTATION.md §4.24.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',
+2
View File
@@ -19,6 +19,7 @@ const SCHEMA = [
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } }, { name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } }, { name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } }, { name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
]; ];
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
@@ -27,6 +28,7 @@ const LABELS: Record<string, string> = {
icon_size: 'Размер иконок, % ширины плана', icon_size: 'Размер иконок, % ширины плана',
show_temperature: 'Показывать температуру', show_temperature: 'Показывать температуру',
live_states: 'Живые состояния (вкл/выкл, открыто…)', live_states: 'Живые состояния (вкл/выкл, открыто…)',
show_signal: 'Показывать сигнал zigbee (LQI)',
}; };
class HouseplanCardEditor extends LitElement { class HouseplanCardEditor extends LitElement {
+65 -8
View File
@@ -9,7 +9,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules'; import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
import './editor'; import './editor';
const CARD_VERSION = '1.1.0'; const CARD_VERSION = '1.2.0';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
interface DevItem { interface DevItem {
@@ -34,9 +34,16 @@ interface CardConfig {
icon_size?: number; icon_size?: number;
show_temperature?: boolean; show_temperature?: boolean;
live_states?: boolean; live_states?: boolean;
show_signal?: boolean;
rooms?: Room[]; 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 fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any; const ev = new Event(type, { bubbles: true, composed: true }) as any;
ev.detail = detail ?? {}; ev.detail = detail ?? {};
@@ -70,7 +77,7 @@ class HouseplanCard extends LitElement {
private _defPos: Record<string, { x: number; y: number }> = {}; private _defPos: Record<string, { x: number; y: number }> = {};
private _menuDev: DevItem | null = null; private _menuDev: DevItem | null = null;
private _menuXY = { x: 0, y: 0 }; private _menuXY = { x: 0, y: 0 };
private _tip: { x: number; y: number; title: string; meta: string } | null = null; private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null } | null = null;
private _selId: string | null = null; private _selId: string | null = null;
private _toast = ''; private _toast = '';
private _toastTimer?: number; private _toastTimer?: number;
@@ -100,7 +107,7 @@ class HouseplanCard extends LitElement {
} }
public setConfig(config: CardConfig): void { public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, ...config }; this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (config.default_floor) this._floor = config.default_floor; if (config.default_floor) this._floor = config.default_floor;
} }
@@ -214,6 +221,32 @@ class HouseplanCard extends LitElement {
); );
} }
/** LQI zigbee-устройства: среднее по всем *_linkquality сущностям (для группы — по участникам). */
private _lqiFor(entIds: string[]): number | null {
const vals: number[] = [];
for (const eid of entIds) {
const st = this.hass.states[eid];
const isLqi = /_linkquality$/.test(eid) || (st?.attributes?.unit_of_measurement || '') === 'lqi';
if (!isLqi || !st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) vals.push(v);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
/** Средний LQI zigbee-устройств комнаты. */
private _roomLqi(area: string): number | null {
const vals: number[] = [];
for (const d of this._devices) {
if (d.area !== area) continue;
const l = this._lqiFor(d.entities);
if (l != null) vals.push(l);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
private _tempFor(entIds: string[]): number | null { private _tempFor(entIds: string[]): number | null {
for (const eid of entIds) { for (const eid of entIds) {
if (!this._isTempEntity(eid)) continue; if (!this._isTempEntity(eid)) continue;
@@ -251,7 +284,9 @@ class HouseplanCard extends LitElement {
const key = name + '|' + area; const key = name + '|' + area;
if (seen[key]) continue; if (seen[key]) continue;
seen[key] = 1; seen[key] = 1;
const icon = iconFor(name, dev.model); let icon = iconFor(name, dev.model);
// устройство с сущностью lock.* — всегда «замочек», как бы оно ни называлось
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const room = this._areaRoom(area)!; const room = this._areaRoom(area)!;
const item: DevItem = { const item: DevItem = {
id: dev.id, id: dev.id,
@@ -453,9 +488,9 @@ class HouseplanCard extends LitElement {
}, 3500); }, 3500);
} }
private _showTip(ev: MouseEvent, title: string, meta: string): void { private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null): void {
if (this._drag) return; if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta }; this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
} }
// ---------- рендер ---------- // ---------- рендер ----------
@@ -510,7 +545,9 @@ class HouseplanCard extends LitElement {
class="room ${bg ? 'overlay' : 'yard'}" class="room ${bg ? 'overlay' : 'yard'}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8"
@click=${() => this._clickRoom(r)} @click=${() => this._clickRoom(r)}
@mousemove=${(e: MouseEvent) => this._showTip(e, r.name, 'комната — открыть зону')} @mousemove=${(e: MouseEvent) =>
this._showTip(e, r.name, 'комната — открыть зону',
this._config?.show_signal ? this._roomLqi(r.area) : null)}
@mouseleave=${() => (this._tip = null)} @mouseleave=${() => (this._tip = null)}
></rect> ></rect>
${!bg ? svg`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + 26}">${r.name}</text>` : nothing}`, ${!bg ? svg`<text class="rlabel" x="${r.x + r.w / 2}" y="${r.y + 26}">${r.name}</text>` : nothing}`,
@@ -526,6 +563,10 @@ class HouseplanCard extends LitElement {
${this._tip ${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px"> ? 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} <b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.lqi != null
? html`<span class="m">средний сигнал zigbee:
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
: nothing}
</div>` </div>`
: nothing} : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing} ${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
@@ -539,12 +580,14 @@ class HouseplanCard extends LitElement {
const top = ((p.y - vb[1]) / vb[3]) * 100; const top = ((p.y - vb[1]) / vb[3]) * 100;
const cls = this._stateClass(d); const cls = this._stateClass(d);
const temp = this._liveTemp(d); const temp = this._liveTemp(d);
const lqi = this._config?.show_signal ? this._lqiFor(d.entities) : null;
return html`<div return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''}" class="dev ${cls} ${this._selId === d.id ? 'sel' : ''}"
style="left:${left}%;top:${top}%" style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => this._clickDevice(e, d)} @click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) => @mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name, d.model + (temp != null ? ' · ' + temp + '°' : ''))} this._showTip(e, d.name,
d.model + (temp != null ? ' · ' + temp + '°' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
@mouseleave=${() => (this._tip = null)} @mouseleave=${() => (this._tip = null)}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)} @pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)} @pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@@ -552,6 +595,7 @@ class HouseplanCard extends LitElement {
> >
<ha-icon icon="${d.icon}"></ha-icon> <ha-icon icon="${d.icon}"></ha-icon>
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing} ${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`; </div>`;
} }
@@ -807,6 +851,19 @@ class HouseplanCard extends LitElement {
white-space: nowrap; white-space: nowrap;
pointer-events: none; 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 { .editbar {
display: flex; display: flex;
align-items: center; align-items: center;
+2 -3
View File
@@ -29,7 +29,7 @@ const ICON_RULES: Array<[string, string]> = [
['выключат|switch', 'mdi:light-switch'], ['выключат|switch', 'mdi:light-switch'],
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'], ['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'],
['камер|camera', 'mdi:cctv'], ['камер|camera', 'mdi:cctv'],
['замок|ttlock|lock', 'mdi:lock-smart'], ['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'],
['ворота|garage', 'mdi:garage-variant'], ['ворота|garage', 'mdi:garage-variant'],
['калитк|door|открыт', 'mdi:door'], ['калитк|door|открыт', 'mdi:door'],
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'], ['счётчик|счетчик|kws|meter', 'mdi:meter-electric'],
@@ -60,5 +60,4 @@ export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan', 'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater', 'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
'alarm_control_panel', 'sensor', 'binary_sensor', 'event', 'button', 'alarm_control_panel', 'sensor', 'binary_sensor', 'event', 'button',
'number', 'select', 'update', 'number', 'select',
];