mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
File diff suppressed because one or more lines are too long
Vendored
+21
-5
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
Executable
+68
@@ -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
@@ -7,109 +7,109 @@ export const ROOMS: Room[] = [
|
||||
"floor": "f1",
|
||||
"name": "Гостиная",
|
||||
"area": "gostinaia_na_dache",
|
||||
"x": 510,
|
||||
"y": 205,
|
||||
"w": 430,
|
||||
"h": 310
|
||||
"x": 518,
|
||||
"y": 226,
|
||||
"w": 420,
|
||||
"h": 292
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевой с/у",
|
||||
"area": "gostevoi_s_u",
|
||||
"x": 600,
|
||||
"y": 515,
|
||||
"w": 190,
|
||||
"h": 170
|
||||
"x": 615,
|
||||
"y": 524,
|
||||
"w": 162,
|
||||
"h": 156
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Котельная",
|
||||
"area": "kotelnaia",
|
||||
"x": 600,
|
||||
"y": 690,
|
||||
"w": 190,
|
||||
"h": 180
|
||||
"x": 614,
|
||||
"y": 686,
|
||||
"w": 163,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Кладовка уличная",
|
||||
"area": "kladovka",
|
||||
"x": 510,
|
||||
"y": 690,
|
||||
"w": 90,
|
||||
"h": 180
|
||||
"x": 518,
|
||||
"y": 686,
|
||||
"w": 82,
|
||||
"h": 169
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Прихожая",
|
||||
"area": "prikhozhaia",
|
||||
"x": 790,
|
||||
"x": 784,
|
||||
"y": 600,
|
||||
"w": 150,
|
||||
"h": 270
|
||||
"w": 152,
|
||||
"h": 255
|
||||
},
|
||||
{
|
||||
"floor": "f1",
|
||||
"name": "Гостевая спальня",
|
||||
"area": "gostevaia_komnata",
|
||||
"x": 945,
|
||||
"y": 600,
|
||||
"w": 255,
|
||||
"h": 270
|
||||
"x": 949,
|
||||
"y": 611,
|
||||
"w": 201,
|
||||
"h": 245
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Майя",
|
||||
"area": "detskaia_maiia",
|
||||
"x": 365,
|
||||
"y": 40,
|
||||
"w": 435,
|
||||
"h": 315
|
||||
"x": 389,
|
||||
"y": 58,
|
||||
"w": 424,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детская Элина",
|
||||
"area": "detskaia_elina",
|
||||
"x": 810,
|
||||
"y": 40,
|
||||
"w": 550,
|
||||
"h": 315
|
||||
"x": 830,
|
||||
"y": 58,
|
||||
"w": 426,
|
||||
"h": 297
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Детский с/у",
|
||||
"area": "detskii_s_u",
|
||||
"x": 390,
|
||||
"x": 389,
|
||||
"y": 360,
|
||||
"w": 260,
|
||||
"h": 180
|
||||
"w": 264,
|
||||
"h": 168
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер с/у",
|
||||
"area": "master_s_u",
|
||||
"x": 390,
|
||||
"y": 540,
|
||||
"w": 260,
|
||||
"h": 160
|
||||
"x": 389,
|
||||
"y": 545,
|
||||
"w": 264,
|
||||
"h": 155
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Мастер-спальня",
|
||||
"area": "spalnia",
|
||||
"x": 650,
|
||||
"y": 600,
|
||||
"w": 330,
|
||||
"h": 320
|
||||
"x": 657,
|
||||
"y": 607,
|
||||
"w": 320,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"floor": "f2",
|
||||
"name": "Кабинет",
|
||||
"area": "kabinet",
|
||||
"x": 985,
|
||||
"y": 600,
|
||||
"w": 290,
|
||||
"h": 320
|
||||
"x": 984,
|
||||
"y": 607,
|
||||
"w": 274,
|
||||
"h": 311
|
||||
},
|
||||
{
|
||||
"floor": "yard",
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Правила прототипа — перенесены 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',
|
||||
@@ -19,6 +19,7 @@ const SCHEMA = [
|
||||
{ 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> = {
|
||||
@@ -27,6 +28,7 @@ const LABELS: Record<string, string> = {
|
||||
icon_size: 'Размер иконок, % ширины плана',
|
||||
show_temperature: 'Показывать температуру',
|
||||
live_states: 'Живые состояния (вкл/выкл, открыто…)',
|
||||
show_signal: 'Показывать сигнал zigbee (LQI)',
|
||||
};
|
||||
|
||||
class HouseplanCardEditor extends LitElement {
|
||||
|
||||
+65
-8
@@ -9,7 +9,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
||||
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||
import './editor';
|
||||
|
||||
const CARD_VERSION = '1.1.0';
|
||||
const CARD_VERSION = '1.2.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
|
||||
interface DevItem {
|
||||
@@ -34,9 +34,16 @@ interface CardConfig {
|
||||
icon_size?: number;
|
||||
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 ?? {};
|
||||
@@ -70,7 +77,7 @@ class HouseplanCard extends LitElement {
|
||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||
private _menuDev: DevItem | null = null;
|
||||
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 _toast = '';
|
||||
private _toastTimer?: number;
|
||||
@@ -100,7 +107,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
for (const eid of entIds) {
|
||||
if (!this._isTempEntity(eid)) continue;
|
||||
@@ -251,7 +284,9 @@ class HouseplanCard extends LitElement {
|
||||
const key = name + '|' + area;
|
||||
if (seen[key]) continue;
|
||||
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 item: DevItem = {
|
||||
id: dev.id,
|
||||
@@ -453,9 +488,9 @@ class HouseplanCard extends LitElement {
|
||||
}, 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;
|
||||
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'}"
|
||||
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8"
|
||||
@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)}
|
||||
></rect>
|
||||
${!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
|
||||
? 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}
|
||||
${this._tip.lqi != null
|
||||
? html`<span class="m">средний сигнал zigbee:
|
||||
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
|
||||
: nothing}
|
||||
</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 cls = this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const lqi = this._config?.show_signal ? this._lqiFor(d.entities) : null;
|
||||
return html`<div
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''}"
|
||||
style="left:${left}%;top:${top}%"
|
||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||
@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)}
|
||||
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
|
||||
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
|
||||
@@ -552,6 +595,7 @@ class HouseplanCard extends LitElement {
|
||||
>
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||||
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -807,6 +851,19 @@ class HouseplanCard extends LitElement {
|
||||
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;
|
||||
|
||||
+2
-3
@@ -29,7 +29,7 @@ const ICON_RULES: Array<[string, string]> = [
|
||||
['выключат|switch', 'mdi:light-switch'],
|
||||
['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'],
|
||||
['камер|camera', 'mdi:cctv'],
|
||||
['замок|ttlock|lock', 'mdi:lock-smart'],
|
||||
['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'],
|
||||
['ворота|garage', 'mdi:garage-variant'],
|
||||
['калитк|door|открыт', 'mdi:door'],
|
||||
['счётчик|счетчик|kws|meter', 'mdi:meter-electric'],
|
||||
@@ -60,5 +60,4 @@ 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', 'update',
|
||||
];
|
||||
'number', 'select',
|
||||
Reference in New Issue
Block a user