feat v1.13.0: universality — floors-import wizard, editable icon rules, tap actions (phase 9)

- floors wizard: spaces from the HA floor registry, step-by-step with mandatory
  plan upload per floor, skip support, auto-markup after the last one
- icon rules as data (settings.icon_rules) + in-card editor with live test,
  invalid-regex guard, bilingual defaults, device_class fallback chain
- tap_action (card) + per-device override; security model: card-wide toggle only
  for light/switch/fan/humidifier, explicit override for covers, never lock/alarm;
  600ms long-press always opens the info card
- i18n dictionaries → src/i18n/*.json (+parity tests); light-theme badge fixes
- frontend tests 15→28
This commit is contained in:
Matysh
2026-07-06 00:54:13 +03:00
parent 8b22fbed05
commit afa8398602
26 changed files with 1479 additions and 482 deletions
+18
View File
@@ -84,6 +84,8 @@ Nothing else needs to be specified — everything else is configured right on th
### Step 1. Add a space (floor)
On first open the plan is still empty — House Plan immediately offers to create the first space.
If your Home Assistant already has **floors** configured, a wizard offers to create a space
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
@@ -125,6 +127,22 @@ Device icons can be **dragged with the mouse at any time** — no separate "edit
![Dragging icons — available at all times](docs/images/06-edit.png)
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
### Icon rules
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
### Step 6. Adding your own devices manually
Not everything has to be left to the automation. With the **** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
+22
View File
@@ -85,6 +85,10 @@ title: План дома
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
пространство для каждого: названия подставятся сами, план попросит по очереди,
любой этаж можно пропустить.
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
@@ -125,6 +129,24 @@ title: План дома
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Управление с плана (tap actions)
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
нажатие** всегда открывает инфо-карточку.
### Правила иконок
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
правило не подошло — решает *device class* сущности (термометр для датчиков
температуры и т.п.).
### Шаг 6. Добавление своих устройств вручную
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.12.0"
VERSION = "1.13.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.12.0"
"version": "1.13.0"
}
@@ -103,6 +103,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
+189 -56
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## v1.13.0 — 2026-07-05 (universality: floors import, icon rules, tap actions)
- **Floors import wizard**: on first run, if the HA registry has floors, the card
offers to create a space per floor — names prefilled, the (mandatory) plan image is
requested step by step, any floor can be skipped; after the last one the room-markup
mode opens automatically. No floors / old HA → the old single-dialog onboarding.
- **Editable icon rules**: the built-in "name pattern → MDI icon" rules are now data
(`settings.icon_rules`) with an in-card editor (⬡ in the header): reorder, delete,
add, live test field, invalid-regex highlighting, one-click reset to the bilingual
(EN/RU) defaults. Fallback chain: rules → entity `device_class` → generic chip.
Invalid user regexes are skipped safely.
- **Tap actions**: `tap_action` card option (`info` default | `more-info` | `toggle`)
plus a per-device override in the device dialog. Safety model: a card-wide toggle
only affects lights/switches/fans/humidifiers; covers/valves need a conscious
per-device toggle; locks and alarm panels never toggle from the plan. A long press
(600 ms) always opens the info card.
- **i18n dictionaries moved to JSON** (`src/i18n/{en,ru}.json`) — new languages can be
contributed without touching TypeScript; tests enforce key and placeholder parity.
- Light-theme pass: hardcoded dark badge backgrounds replaced with theme variables.
- Tests: 28 frontend (was 15) — tap-action resolver incl. security cases, icon-rule
compilation/overrides/device-class fallback, floors sorting, i18n parity.
## v1.12.0 — 2026-07-05 (Quality Scale: Bronze + selected Silver/Gold)
Backend brought to Integration Quality Scale patterns (custom integrations are not
formally graded; progress is tracked in `custom_components/houseplan/quality_scale.yaml`):
+4 -2
View File
@@ -13,14 +13,14 @@
| Item | State |
|---|---|
| Version | **v1.12.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Version | **v1.13.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 15 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13 + pytest-homeassistant-custom-component; skipped locally — sandbox has py3.10) |
| Tests | 28 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
## Recent milestones (details in CHANGELOG.md)
@@ -35,6 +35,8 @@
- **v1.12.0** — Quality Scale conformance: runtime_data, test-before-setup, unloading,
single_config_entry, Store migrations hook, diagnostics, repairs, system health,
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
## Where things live
+24 -2
View File
@@ -1,17 +1,18 @@
{
"name": "houseplan-card",
"version": "1.0.0",
"version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.0.0",
"version": "1.12.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
},
"devDependencies": {
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
@@ -85,6 +86,27 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.13.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -11,9 +11,10 @@
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
},
"devDependencies": {
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
@@ -21,4 +22,4 @@
"dependencies": {
"lit": "^3.1.3"
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
@@ -9,5 +10,5 @@ export default {
format: 'es',
sourcemap: false,
},
plugins: [resolve(), typescript(), terser({ format: { comments: false } })],
plugins: [resolve(), json(), typescript(), terser({ format: { comments: false } })],
};
+21 -4
View File
@@ -2,7 +2,7 @@
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM only the hass object.
*/
import { iconFor, DOMAIN_PRIORITY } from './rules';
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
@@ -18,6 +18,8 @@ export interface BuildCtx {
firstSpaceId: string;
/** Localized display strings for generated device names. */
loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string;
/** Compiled icon rules (instance overrides or built-in defaults). */
iconRules?: CompiledIconRule[];
}
export function entitiesByDevice(hass: any): Record<string, string[]> {
@@ -121,6 +123,18 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
return res;
}
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
const byRules = iconFor(name, model, rules);
if (byRules !== FALLBACK_ICON) return byRules;
const classes: string[] = [];
for (const eid of entIds) {
const dc = hass.states[eid]?.attributes?.device_class;
if (dc) classes.push(dc);
}
return iconFromDeviceClasses(classes) ?? FALLBACK_ICON;
}
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.name) item.name = m.name;
@@ -129,11 +143,12 @@ function applyMarker(item: DevItem, m: Marker): void {
item.link = m.link ?? null;
item.description = m.description ?? null;
item.pdfs = m.pdfs || [];
item.tapAction = m.tap_action ?? null;
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
@@ -167,7 +182,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}
const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
const key = name + '|' + area;
let icon = iconFor(name, dev.model);
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
@@ -218,7 +233,9 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
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';
let icon = dev
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
: 'mdi:help-circle';
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
+14
View File
@@ -63,6 +63,19 @@ class HouseplanCardEditor extends LitElement {
},
},
},
{
name: 'tap_action',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'info', label: t(L, 'tap.info') },
{ value: 'more-info', label: t(L, 'tap.more_info') },
{ value: 'toggle', label: t(L, 'tap.toggle') },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
@@ -78,6 +91,7 @@ class HouseplanCardEditor extends LitElement {
title: t(L, 'editor.title'),
default_floor: t(L, 'editor.default_floor'),
language: t(L, 'editor.language'),
tap_action: t(L, 'editor.tap_action'),
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
+276 -11
View File
@@ -7,10 +7,13 @@
* The icon layout is stored on the server (houseplan/layout/*), fallback localStorage.
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { EXCLUDED_DOMAINS } from './rules';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
} from './rules';
import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
averageLqi, fitView, declump, safeUrl,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
} from './logic';
import { buildDevices, lqiFor, tempFor } from './devices';
import type {
@@ -20,7 +23,7 @@ import './editor';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.12.0';
const CARD_VERSION = '1.13.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -89,6 +92,13 @@ class HouseplanCard extends LitElement {
private _roViewport?: ResizeObserver;
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
private _rulesCompiledSrc = '';
private _rulesCompiled: CompiledIconRule[] | undefined;
private _infoCard: DevItem | null = null;
private _markerDialog: {
devId?: string; // the icon being edited (if any)
@@ -96,6 +106,7 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = auto
tapAction: string; // '' = card default
model: string;
link: string;
description: string;
@@ -114,6 +125,8 @@ class HouseplanCard extends LitElement {
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _holdTimer?: number;
private _holdFired = false;
static properties = {
hass: { attribute: false },
@@ -134,6 +147,8 @@ class HouseplanCard extends LitElement {
_roomDialog: { state: true },
_spaceDialog: { state: true },
_infoCard: { state: true },
_rulesDialog: { state: true },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
_view: { state: true },
@@ -146,6 +161,7 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
if (this._unsubCfg) {
@@ -307,6 +323,18 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
/** Compiled icon rules: instance settings override the built-in defaults. */
private get _iconRules(): CompiledIconRule[] | undefined {
const custom = this._settings.icon_rules;
if (!custom || !Array.isArray(custom) || !custom.length) return undefined;
const src = JSON.stringify(custom);
if (src !== this._rulesCompiledSrc) {
this._rulesCompiledSrc = src;
this._rulesCompiled = compileIconRules(custom);
}
return this._rulesCompiled;
}
private get _excluded(): Set<string> {
const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
@@ -334,10 +362,16 @@ class HouseplanCard extends LitElement {
this._loadOk &&
this._model.length === 0 &&
!this._spaceDialog &&
!this._importDialog &&
!this._onboardingShown
) {
this._onboardingShown = true;
this._openSpaceDialog('create');
const floors = floorsOf(this.hass);
if (floors.length) {
this._importDialog = { floors: floors.map((f) => ({ ...f, checked: true })) };
} else {
this._openSpaceDialog('create');
}
}
}
@@ -442,6 +476,7 @@ class HouseplanCard extends LitElement {
showAll: this._showAll,
firstSpaceId: this._model[0]?.id || '',
loc: (k) => this._t(k),
iconRules: this._iconRules,
});
this._defPos = this._defaultPositions();
}
@@ -580,8 +615,20 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick) return;
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._markup) return;
const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
if (action === 'toggle' && d.primary) {
this.hass
.callService('homeassistant', 'toggle', { entity_id: d.primary })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
return;
}
if (action === 'more-info' && d.primary) {
this._openMoreInfo(d.primary);
return;
}
this._infoCard = d;
}
@@ -785,6 +832,17 @@ class HouseplanCard extends LitElement {
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
// long-press always opens the info card — keeps metadata reachable
// even when the tap action is set to toggle
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
this._holdFired = true;
this._drag = null;
this._infoCard = d;
}
}, 600);
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
@@ -796,7 +854,10 @@ class HouseplanCard extends LitElement {
const v = this._viewOr(vb);
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) {
this._drag.moved = true;
clearTimeout(this._holdTimer);
}
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
@@ -804,6 +865,7 @@ class HouseplanCard extends LitElement {
}
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
clearTimeout(this._holdTimer);
if (!this._drag || this._drag.id !== d.id) return;
const moved = this._drag.moved;
this._drag = moved ? this._drag : null;
@@ -1103,6 +1165,7 @@ class HouseplanCard extends LitElement {
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '',
icon: d.marker?.icon || '',
tapAction: d.marker?.tap_action || '',
model: d.model || '',
link: d.link || '',
description: d.description || '',
@@ -1112,7 +1175,7 @@ class HouseplanCard extends LitElement {
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', model: '',
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
@@ -1275,6 +1338,7 @@ class HouseplanCard extends LitElement {
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
tap_action: dlg.tapAction || null,
model: dlg.model.trim() || null,
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
@@ -1451,13 +1515,18 @@ class HouseplanCard extends LitElement {
if (d.mode === 'create') this._space = sp.id;
this._regSignature = '';
this._maybeRebuildDevices();
if (wasFirst) {
if (this._importQueue.length) {
// floors-import wizard: proceed to the next floor
this._openNextImport();
} else if (wasFirst || this._importTotal > 0) {
// guide the user onward: straight into room markup mode
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._tool = 'draw';
this._path = [];
this._cursorPt = null;
this._showToast(this._t('toast.space_added_onboard'));
this._showToast(this._t(wasFirst && !this._importTotal ? 'toast.space_added_onboard' : 'import.done'));
} else {
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
}
@@ -1494,6 +1563,179 @@ class HouseplanCard extends LitElement {
}
// ================= FLOORS IMPORT WIZARD =================
private _startImport(): void {
const dlg = this._importDialog;
if (!dlg) return;
const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name);
this._importDialog = null;
if (!titles.length) {
this._openSpaceDialog('create');
return;
}
this._importQueue = titles;
this._importTotal = titles.length;
this._openNextImport();
}
/** Open the space dialog for the next queued floor (title prefilled, plan required). */
private _openNextImport(): void {
const title = this._importQueue.shift();
if (title === undefined) return;
this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, busy: false };
}
/** Skip the current floor of the wizard without creating a space. */
private _skipImport(): void {
this._spaceDialog = null;
if (this._importQueue.length) this._openNextImport();
else if (this._importTotal > 0 && this._model.length) {
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._showToast(this._t('import.done'));
}
}
private _renderImportDialog(): TemplateResult {
const d = this._importDialog!;
const n = d.floors.filter((f) => f.checked).length;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:home-floor-1"></ha-icon>${this._t('import.title')}</div>
<div class="body">
<div class="rhint">${this._t('import.hint')}</div>
${d.floors.map(
(f, i) => html`<label class="floorrow">
<input type="checkbox" .checked=${f.checked}
@change=${(e: Event) => {
const floors = [...d.floors];
floors[i] = { ...f, checked: (e.target as HTMLInputElement).checked };
this._importDialog = { floors };
}} />
<span>${f.name}</span>
${f.level != null ? html`<span class="floorlvl">L${f.level}</span>` : nothing}
</label>`,
)}
</div>
<div class="row">
<button class="btn ghost" @click=${() => { this._importDialog = null; this._openSpaceDialog('create'); }}>
${this._t('import.manual')}
</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!n}>
<ha-icon icon="mdi:import"></ha-icon>${this._t('import.start', { n })}
</button>
</div>
</div>
</div>`;
}
// ================= ICON RULES EDITOR =================
private _openRulesDialog = (): void => {
if (!this._norm) return;
const custom = this._settings.icon_rules;
const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r }));
this._rulesDialog = { rules, test: '', busy: false };
};
private _rulesSet(rules: IconRule[]): void {
this._rulesDialog = { ...this._rulesDialog!, rules };
}
private async _saveRules(): Promise<void> {
const dlg = this._rulesDialog;
if (!dlg || dlg.busy) return;
const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim());
this._rulesDialog = { ...dlg, busy: true };
try {
const cfg = this._serverCfg!;
const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES);
const settings: any = { ...cfg.settings };
if (isDefault) delete settings.icon_rules;
else settings.icon_rules = cleaned;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._rulesDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('rules.saved'));
} catch (e: any) {
this._rulesDialog = { ...this._rulesDialog!, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) }));
}
}
private _renderRulesDialog(): TemplateResult {
const d = this._rulesDialog!;
const compiled = compileIconRules(d.rules);
const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null;
const move = (i: number, delta: number) => {
const r = [...d.rules];
const j = i + delta;
if (j < 0 || j >= r.length) return;
[r[i], r[j]] = [r[j], r[i]];
this._rulesSet(r);
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('rules.title')}</div>
<div class="body">
<div class="rhint">${this._t('rules.hint')}</div>
<div class="rtest">
<input class="namein" type="text" placeholder=${this._t('rules.test_ph')}
.value=${d.test}
@input=${(e: Event) => (this._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} />
${testIcon ? html`<ha-icon icon=${testIcon}></ha-icon><span class="rtesticon">${testIcon}</span>` : nothing}
</div>
${d.rules.map((r, i) => {
const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern);
return html`<div class="rrow">
<input class="namein rpat ${bad ? 'bad' : ''}" type="text"
placeholder=${this._t('rules.pattern_ph')}
title=${bad ? this._t('rules.invalid') : ''}
.value=${r.pattern}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<input class="namein ricon" type="text" placeholder=${this._t('rules.icon_ph')}
.value=${r.icon}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, icon: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<ha-icon class="rprev" icon=${r.icon || 'mdi:chip'}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-up" title=${this._t('btn.up')}
@click=${() => move(i, -1)}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-down" title=${this._t('btn.down')}
@click=${() => move(i, 1)}></ha-icon>
<ha-icon class="ract del" icon="mdi:close" title=${this._t('btn.delete')}
@click=${() => this._rulesSet(d.rules.filter((_, j) => j !== i))}></ha-icon>
</div>`;
})}
<button class="btn ghost" @click=${() => this._rulesSet([...d.rules, { pattern: '', icon: '' }])}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t('rules.add')}
</button>
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
${this._t('rules.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._rulesDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveRules} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
// ================= render =================
protected render(): TemplateResult | typeof nothing {
@@ -1515,6 +1757,7 @@ class HouseplanCard extends LitElement {
: html`<p class="muted">${this._t('empty.install')}</p>`}
</div>
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>`;
}
@@ -1581,6 +1824,9 @@ class HouseplanCard extends LitElement {
</button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button>`
: nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
@@ -1637,6 +1883,8 @@ class HouseplanCard extends LitElement {
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
@@ -1837,6 +2085,14 @@ class HouseplanCard extends LitElement {
)}
</select>
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['', 'tap.auto'], ['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || '') === v}>${this._t(k as any)}</option>`,
)}
</select>
<label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
@@ -1895,7 +2151,13 @@ class HouseplanCard extends LitElement {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}</div>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
? html`<span class="importprog">${this._t('import.progress', {
i: this._importTotal - this._importQueue.length,
n: this._importTotal,
})}</span>`
: nothing}</div>
<div class="body">
<label>${this._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this._t('space.title_ph')}
@@ -1922,7 +2184,10 @@ class HouseplanCard extends LitElement {
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._spaceDialog = null)}>${this._t('btn.cancel')}</button>
${this._importTotal > 0 && d.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>${this._t('btn.skip')}</button>`
: nothing}
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSpaceDialog}
?disabled=${!d.title.trim() || !(d.planFile || d.planUrl) || d.busy}
title=${!(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
+9 -294
View File
@@ -1,306 +1,21 @@
/**
* Card UI localization. Language is resolved from the card config
* (`language: en|ru`) or, by default, from the HA user profile
* (hass.locale.language); anything that is not Russian falls back to English.
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
* languages can be contributed without touching TypeScript. The language is
* resolved from the card config (`language: en|ru`) or, by default, from the
* HA user profile (hass.locale.language); anything that is not a known
* language falls back to English.
*/
import en from './i18n/en.json';
import ru from './i18n/ru.json';
export type Lang = 'en' | 'ru';
const en = {
'card.title': 'House plan',
'count.devices': '{n} dev.',
'empty.no_spaces': 'No spaces yet.',
'empty.add_first': 'Add the first space and upload a floor plan.',
'empty.install': 'Install the House Plan integration and add it in "Devices & services".',
'btn.add_space': 'Add space',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.close': 'Close',
'btn.delete': 'Delete',
'btn.remove': 'Remove',
'btn.edit': 'Edit',
'btn.open_in_ha': 'Open in HA',
'btn.reset': 'Reset',
'btn.attach': 'Attach…',
'btn.upload': 'Upload…',
'btn.replace': 'Replace…',
'btn.no_area': 'No area',
'title.zoom_in': 'Zoom in',
'title.zoom_out': 'Zoom out',
'title.zoom_reset': 'Reset zoom',
'title.add_device': 'Add a device to the plan',
'title.show_all': 'Show all area devices (no curation)',
'title.reset_layout': 'Reset icon positions to auto layout',
'title.markup': 'Room markup: grid, lines, outlines',
'title.configure_space': 'Configure space',
'title.add_space': 'Add space',
'title.markup_add': 'Add a room: connect grid dots with lines until the outline closes',
'title.markup_erase': 'Erase a line: click the line',
'title.markup_delroom': 'Delete a room: click inside the room',
'title.no_area_room': 'Decorative room without an HA area (e.g. a hallway)',
'title.choose_area': 'Select a Home Assistant area',
'title.need_plan': 'Upload a floor-plan image',
'markup.add': 'Add',
'markup.erase': 'Erase',
'markup.delete': 'Delete',
'markup.hint_points': 'points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one',
'markup.hint_start': 'click a grid dot to start the outline',
'tip.room': 'room — open the area',
'tip.lqi': 'average zigbee signal:',
'info.device_header': 'Device on the plan',
'info.model': 'Model',
'info.state': 'State',
'info.link': 'Link',
'info.manuals': 'Manuals',
'info.none': 'No additional information',
'marker.new_device': 'New device',
'marker.name_label': 'Name (shown on the plan)',
'marker.name_ph': 'Name',
'marker.binding_label': 'Bind to an HA device',
'marker.virtual_option': 'Virtual device (no binding)',
'marker.search_ph': 'Search device / group…',
'marker.nothing_found': 'nothing found',
'marker.room_label': 'Room',
'marker.room_override': ' (override placement)',
'marker.room_choose': '— select a room —',
'marker.room_auto': '— by device area (auto) —',
'marker.icon_label': 'Icon',
'marker.icon_ph': 'mdi:… (empty = auto)',
'marker.model_label': 'Model',
'marker.model_ph': 'e.g. Aqara T&H',
'marker.link_label': 'Link',
'marker.desc_label': 'Description',
'marker.desc_ph': 'Notes, specs…',
'marker.manuals_label': 'Manuals (PDF etc.)',
'marker.sub_device': 'device',
'marker.sub_z2m_group': ' · Z2M group',
'marker.sub_group': 'group',
'marker.sub_helper': 'helper',
'space.new': 'New space',
'space.header': 'Space',
'space.title_label': 'Title',
'space.title_ph': 'e.g. Garage',
'space.plan_label': 'Floor plan (background)',
'space.no_plan': 'no plan image',
'space.plan_alt': 'plan',
'room.new': 'New room',
'room.name_label': 'Display name',
'room.name_ph': 'e.g. Terrace',
'room.area_label': 'Home Assistant area (unassigned)',
'room.no_area_option': '— no area —',
'room.default_name': 'Room',
'device.unnamed': 'unnamed',
'device.light_group': 'light group',
'device.fallback': 'device',
'device.virtual': 'virtual device',
'confirm.reset_layout': 'Reset all icon positions to the auto layout?',
'confirm.delete_room': 'Delete room "{name}"?',
'confirm.remove_marker': 'Remove "{name}" from the plan?',
'confirm.delete_space': 'Delete space "{title}" with all its rooms and markup?',
'toast.pos_save_failed': 'Failed to save position: {err}',
'toast.no_entity': 'The device has no suitable entity',
'toast.markup_needs_server': 'Markup is available after the config is moved to the server',
'toast.conflict': 'Config was changed in another window — data refreshed, repeat your last action',
'toast.cfg_save_failed': 'Failed to save config: {err}',
'toast.room_saved': 'Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.',
'toast.room_saved_no_area': 'Room saved ({n}, no area). Outline the next one or exit markup.',
'toast.marker_needs_server': 'Device editing is available after the config is moved to the server',
'toast.virtual_name_required': 'Enter a name for the virtual device',
'toast.marker_saved': 'Device saved',
'toast.marker_removed': 'Device removed from the plan',
'toast.integration_missing': 'The House Plan integration is not installed — management unavailable',
'toast.plan_formats': 'Supported formats: SVG, PNG, JPG, WebP',
'toast.plan_required': 'Upload a floor plan — it is required',
'toast.space_added_onboard': 'Space added. Outline the rooms: click grid dots and close the contour.',
'toast.space_added': 'Space added',
'toast.space_saved': 'Space saved',
'toast.space_deleted': 'Space deleted',
'toast.delete_failed': 'Delete failed: {err}',
'toast.error': 'Error: {err}',
'toast.file_failed': 'File "{name}" was not uploaded: {err}',
'toast.files_attached': 'Files attached: {n}',
'err.unknown': 'unknown error',
'err.code': 'code {code}',
'err.too_large': 'file larger than {mb} MB',
'err.bad_ext': 'unsupported type (PDF/image expected)',
'err.unauthorized': 'administrator rights required',
'editor.title': 'Title',
'editor.default_floor': 'Default space',
'editor.icon_size': 'Icon size, % of plan width',
'editor.show_temperature': 'Show temperature',
'editor.live_states': 'Live states (on/off, open…)',
'editor.show_signal': 'Show zigbee signal (LQI)',
'editor.language': 'Interface language',
'editor.lang_auto': 'Auto (HA profile)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
type Key = keyof typeof en;
const ru: Record<Key, string> = {
'card.title': 'План дома',
'count.devices': '{n} устр.',
'empty.no_spaces': 'Пространств пока нет.',
'empty.add_first': 'Добавьте первое пространство и загрузите план этажа.',
'empty.install': 'Установите интеграцию House Plan и добавьте запись в «Устройства и службы».',
'btn.add_space': 'Добавить пространство',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.close': 'Закрыть',
'btn.delete': 'Удалить',
'btn.remove': 'Убрать',
'btn.edit': 'Редактировать',
'btn.open_in_ha': 'Открыть в HA',
'btn.reset': 'Сброс',
'btn.attach': 'Прикрепить…',
'btn.upload': 'Загрузить…',
'btn.replace': 'Заменить…',
'btn.no_area': 'Без зоны',
'title.zoom_in': 'Приблизить',
'title.zoom_out': 'Отдалить',
'title.zoom_reset': 'Сбросить масштаб',
'title.add_device': 'Добавить устройство на план',
'title.show_all': 'Показывать все устройства зоны (без курирования)',
'title.reset_layout': 'Сбросить позиции значков к авто-раскладке',
'title.markup': 'Разметка комнат: сетка, линии, контуры',
'title.configure_space': 'Настроить пространство',
'title.add_space': 'Добавить пространство',
'title.markup_add': 'Добавить комнату: соединяйте точки сетки линиями до замкнутого контура',
'title.markup_erase': 'Стереть линию: клик по линии',
'title.markup_delroom': 'Удалить комнату: клик внутри комнаты',
'title.no_area_room': 'Декоративная комната без привязки к зоне (например, холл)',
'title.choose_area': 'Выберите зону Home Assistant',
'title.need_plan': 'Загрузите подложку (план этажа)',
'markup.add': 'Добавить',
'markup.erase': 'Стереть',
'markup.delete': 'Удалить',
'markup.hint_points': 'точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой',
'markup.hint_start': 'кликните точку сетки, чтобы начать контур',
'tip.room': 'комната — открыть зону',
'tip.lqi': 'средний сигнал zigbee:',
'info.device_header': 'Устройство на плане',
'info.model': 'Модель',
'info.state': 'Состояние',
'info.link': 'Ссылка',
'info.manuals': 'Инструкции',
'info.none': 'Нет дополнительной информации',
'marker.new_device': 'Новое устройство',
'marker.name_label': 'Имя (отображается на плане)',
'marker.name_ph': 'Название',
'marker.binding_label': 'Привязка к устройству HA',
'marker.virtual_option': 'Виртуальное устройство (без привязки)',
'marker.search_ph': 'Поиск устройства / группы…',
'marker.nothing_found': 'ничего не найдено',
'marker.room_label': 'Комната',
'marker.room_override': ' (переопределить размещение)',
'marker.room_choose': '— выберите комнату —',
'marker.room_auto': '— по зоне устройства (авто) —',
'marker.icon_label': 'Иконка',
'marker.icon_ph': 'mdi:… (пусто = авто)',
'marker.model_label': 'Модель',
'marker.model_ph': 'напр. Aqara T&H',
'marker.link_label': 'Ссылка',
'marker.desc_label': 'Описание',
'marker.desc_ph': 'Заметки, характеристики…',
'marker.manuals_label': 'Инструкции (PDF и т.п.)',
'marker.sub_device': 'устройство',
'marker.sub_z2m_group': ' · Z2M-группа',
'marker.sub_group': 'группа',
'marker.sub_helper': 'хелпер',
'space.new': 'Новое пространство',
'space.header': 'Пространство',
'space.title_label': 'Название',
'space.title_ph': 'Например: Гараж',
'space.plan_label': 'Подложка (план)',
'space.no_plan': 'нет подложки',
'space.plan_alt': 'план',
'room.new': 'Новая комната',
'room.name_label': 'Отображаемое имя',
'room.name_ph': 'Например: Терраса',
'room.area_label': 'Зона Home Assistant (свободные)',
'room.no_area_option': '— без зоны —',
'room.default_name': 'Комната',
'device.unnamed': 'без имени',
'device.light_group': 'группа света',
'device.fallback': 'устройство',
'device.virtual': 'виртуальное устройство',
'confirm.reset_layout': 'Сбросить позиции всех иконок к авто-раскладке?',
'confirm.delete_room': 'Удалить комнату «{name}»?',
'confirm.remove_marker': 'Убрать «{name}» с плана?',
'confirm.delete_space': 'Удалить пространство «{title}» со всеми комнатами и разметкой?',
'toast.pos_save_failed': 'Не удалось сохранить позицию: {err}',
'toast.no_entity': 'У устройства нет подходящей сущности',
'toast.markup_needs_server': 'Разметка доступна после переноса конфига на сервер',
'toast.conflict': 'Конфиг изменён в другом окне — данные обновлены, повторите последнее действие',
'toast.cfg_save_failed': 'Не удалось сохранить конфиг: {err}',
'toast.room_saved': 'Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.',
'toast.room_saved_no_area': 'Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.',
'toast.marker_needs_server': 'Редактирование устройств доступно после переноса конфига на сервер',
'toast.virtual_name_required': 'Укажите имя виртуального устройства',
'toast.marker_saved': 'Устройство сохранено',
'toast.marker_removed': 'Устройство убрано с плана',
'toast.integration_missing': 'Интеграция House Plan не установлена — управление недоступно',
'toast.plan_formats': 'Поддерживаются SVG, PNG, JPG, WebP',
'toast.plan_required': 'Загрузите подложку — план этажа обязателен',
'toast.space_added_onboard': 'Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.',
'toast.space_added': 'Пространство добавлено',
'toast.space_saved': 'Пространство сохранено',
'toast.space_deleted': 'Пространство удалено',
'toast.delete_failed': 'Ошибка удаления: {err}',
'toast.error': 'Ошибка: {err}',
'toast.file_failed': 'Файл «{name}» не загружен: {err}',
'toast.files_attached': 'Прикреплено файлов: {n}',
'err.unknown': 'неизвестная ошибка',
'err.code': 'код {code}',
'err.too_large': 'файл больше {mb} МБ',
'err.bad_ext': 'недопустимый тип (нужен PDF/изображение)',
'err.unauthorized': 'нужны права администратора',
'editor.title': 'Заголовок',
'editor.default_floor': 'Пространство по умолчанию',
'editor.icon_size': 'Размер иконок, % ширины плана',
'editor.show_temperature': 'Показывать температуру',
'editor.live_states': 'Живые состояния (вкл/выкл, открыто…)',
'editor.show_signal': 'Показывать сигнал zigbee (LQI)',
'editor.language': 'Язык интерфейса',
'editor.lang_auto': 'Авто (профиль HA)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
const DICTS: Record<Lang, Record<Key, string>> = { en, ru };
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
/** Resolve the UI language: explicit config option wins, then the HA profile. */
export function langOf(hass: any, configLang?: string | null): Lang {
if (configLang === 'ru' || configLang === 'en') return configLang;
if (configLang && configLang in DICTS) return configLang as Lang;
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
return l.startsWith('ru') ? 'ru' : 'en';
}
+155
View File
@@ -0,0 +1,155 @@
{
"card.title": "House plan",
"count.devices": "{n} dev.",
"empty.no_spaces": "No spaces yet.",
"empty.add_first": "Add the first space and upload a floor plan.",
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
"btn.add_space": "Add space",
"btn.cancel": "Cancel",
"btn.save": "Save",
"btn.close": "Close",
"btn.delete": "Delete",
"btn.remove": "Remove",
"btn.edit": "Edit",
"btn.open_in_ha": "Open in HA",
"btn.reset": "Reset",
"btn.attach": "Attach…",
"btn.upload": "Upload…",
"btn.replace": "Replace…",
"btn.no_area": "No area",
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no curation)",
"title.reset_layout": "Reset icon positions to auto layout",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
"title.markup_erase": "Erase a line: click the line",
"title.markup_delroom": "Delete a room: click inside the room",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Add",
"markup.erase": "Erase",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
"tip.room": "room — open the area",
"tip.lqi": "average zigbee signal:",
"info.device_header": "Device on the plan",
"info.model": "Model",
"info.state": "State",
"info.link": "Link",
"info.manuals": "Manuals",
"info.none": "No additional information",
"marker.new_device": "New device",
"marker.name_label": "Name (shown on the plan)",
"marker.name_ph": "Name",
"marker.binding_label": "Bind to an HA device",
"marker.virtual_option": "Virtual device (no binding)",
"marker.search_ph": "Search device / group…",
"marker.nothing_found": "nothing found",
"marker.room_label": "Room",
"marker.room_override": " (override placement)",
"marker.room_choose": "— select a room —",
"marker.room_auto": "— by device area (auto) —",
"marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)",
"marker.model_label": "Model",
"marker.model_ph": "e.g. Aqara T&H",
"marker.link_label": "Link",
"marker.desc_label": "Description",
"marker.desc_ph": "Notes, specs…",
"marker.manuals_label": "Manuals (PDF etc.)",
"marker.sub_device": "device",
"marker.sub_z2m_group": " · Z2M group",
"marker.sub_group": "group",
"marker.sub_helper": "helper",
"space.new": "New space",
"space.header": "Space",
"space.title_label": "Title",
"space.title_ph": "e.g. Garage",
"space.plan_label": "Floor plan (background)",
"space.no_plan": "no plan image",
"space.plan_alt": "plan",
"room.new": "New room",
"room.name_label": "Display name",
"room.name_ph": "e.g. Terrace",
"room.area_label": "Home Assistant area (unassigned)",
"room.no_area_option": "— no area —",
"room.default_name": "Room",
"device.unnamed": "unnamed",
"device.light_group": "light group",
"device.fallback": "device",
"device.virtual": "virtual device",
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
"confirm.delete_room": "Delete room \"{name}\"?",
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
"toast.pos_save_failed": "Failed to save position: {err}",
"toast.no_entity": "The device has no suitable entity",
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
"toast.virtual_name_required": "Enter a name for the virtual device",
"toast.marker_saved": "Device saved",
"toast.marker_removed": "Device removed from the plan",
"toast.integration_missing": "The House Plan integration is not installed — management unavailable",
"toast.plan_formats": "Supported formats: SVG, PNG, JPG, WebP",
"toast.plan_required": "Upload a floor plan — it is required",
"toast.space_added_onboard": "Space added. Outline the rooms: click grid dots and close the contour.",
"toast.space_added": "Space added",
"toast.space_saved": "Space saved",
"toast.space_deleted": "Space deleted",
"toast.delete_failed": "Delete failed: {err}",
"toast.error": "Error: {err}",
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
"toast.files_attached": "Files attached: {n}",
"err.unknown": "unknown error",
"err.code": "code {code}",
"err.too_large": "file larger than {mb} MB",
"err.bad_ext": "unsupported type (PDF/image expected)",
"err.unauthorized": "administrator rights required",
"editor.title": "Title",
"editor.default_floor": "Default space",
"editor.icon_size": "Icon size, % of plan width",
"editor.show_temperature": "Show temperature",
"editor.live_states": "Live states (on/off, open…)",
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
"rules.title": "Icon rules",
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
"rules.pattern_ph": "regex, e.g. plug|socket",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Add rule",
"rules.reset": "Reset to defaults",
"rules.test_ph": "Try a device name…",
"rules.invalid": "invalid regex",
"rules.saved": "Icon rules saved",
"btn.up": "Up",
"btn.down": "Down",
"editor.tap_action": "Tap on a device",
"tap.info": "Info card",
"tap.more_info": "HA more-info dialog",
"tap.toggle": "Toggle (lights/switches)",
"tap.auto": "As the card default",
"marker.tap_label": "Tap action for this device",
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
"import.title": "Create spaces from HA floors",
"import.hint": "Your Home Assistant already knows these floors. Pick the ones to turn into plan spaces — you will upload a floor-plan image for each one next. Rooms are then outlined by hand on the plan.",
"import.start": "Create {n} space(s)",
"import.manual": "Start from scratch",
"import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip"
}
+155
View File
@@ -0,0 +1,155 @@
{
"card.title": "План дома",
"count.devices": "{n} устр.",
"empty.no_spaces": "Пространств пока нет.",
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
"btn.add_space": "Добавить пространство",
"btn.cancel": "Отмена",
"btn.save": "Сохранить",
"btn.close": "Закрыть",
"btn.delete": "Удалить",
"btn.remove": "Убрать",
"btn.edit": "Редактировать",
"btn.open_in_ha": "Открыть в HA",
"btn.reset": "Сброс",
"btn.attach": "Прикрепить…",
"btn.upload": "Загрузить…",
"btn.replace": "Заменить…",
"btn.no_area": "Без зоны",
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)",
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_erase": "Стереть линию: клик по линии",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"markup.erase": "Стереть",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
"tip.room": "комната — открыть зону",
"tip.lqi": "средний сигнал zigbee:",
"info.device_header": "Устройство на плане",
"info.model": "Модель",
"info.state": "Состояние",
"info.link": "Ссылка",
"info.manuals": "Инструкции",
"info.none": "Нет дополнительной информации",
"marker.new_device": "Новое устройство",
"marker.name_label": "Имя (отображается на плане)",
"marker.name_ph": "Название",
"marker.binding_label": "Привязка к устройству HA",
"marker.virtual_option": "Виртуальное устройство (без привязки)",
"marker.search_ph": "Поиск устройства / группы…",
"marker.nothing_found": "ничего не найдено",
"marker.room_label": "Комната",
"marker.room_override": " (переопределить размещение)",
"marker.room_choose": "— выберите комнату —",
"marker.room_auto": "— по зоне устройства (авто) —",
"marker.icon_label": "Иконка",
"marker.icon_ph": "mdi:… (пусто = авто)",
"marker.model_label": "Модель",
"marker.model_ph": "напр. Aqara T&H",
"marker.link_label": "Ссылка",
"marker.desc_label": "Описание",
"marker.desc_ph": "Заметки, характеристики…",
"marker.manuals_label": "Инструкции (PDF и т.п.)",
"marker.sub_device": "устройство",
"marker.sub_z2m_group": " · Z2M-группа",
"marker.sub_group": "группа",
"marker.sub_helper": "хелпер",
"space.new": "Новое пространство",
"space.header": "Пространство",
"space.title_label": "Название",
"space.title_ph": "Например: Гараж",
"space.plan_label": "Подложка (план)",
"space.no_plan": "нет подложки",
"space.plan_alt": "план",
"room.new": "Новая комната",
"room.name_label": "Отображаемое имя",
"room.name_ph": "Например: Терраса",
"room.area_label": "Зона Home Assistant (свободные)",
"room.no_area_option": "— без зоны —",
"room.default_name": "Комната",
"device.unnamed": "без имени",
"device.light_group": "группа света",
"device.fallback": "устройство",
"device.virtual": "виртуальное устройство",
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
"toast.no_entity": "У устройства нет подходящей сущности",
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
"toast.virtual_name_required": "Укажите имя виртуального устройства",
"toast.marker_saved": "Устройство сохранено",
"toast.marker_removed": "Устройство убрано с плана",
"toast.integration_missing": "Интеграция House Plan не установлена — управление недоступно",
"toast.plan_formats": "Поддерживаются SVG, PNG, JPG, WebP",
"toast.plan_required": "Загрузите подложку — план этажа обязателен",
"toast.space_added_onboard": "Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"toast.space_added": "Пространство добавлено",
"toast.space_saved": "Пространство сохранено",
"toast.space_deleted": "Пространство удалено",
"toast.delete_failed": "Ошибка удаления: {err}",
"toast.error": "Ошибка: {err}",
"toast.file_failed": "Файл «{name}» не загружен: {err}",
"toast.files_attached": "Прикреплено файлов: {n}",
"err.unknown": "неизвестная ошибка",
"err.code": "код {code}",
"err.too_large": "файл больше {mb} МБ",
"err.bad_ext": "недопустимый тип (нужен PDF/изображение)",
"err.unauthorized": "нужны права администратора",
"editor.title": "Заголовок",
"editor.default_floor": "Пространство по умолчанию",
"editor.icon_size": "Размер иконок, % ширины плана",
"editor.show_temperature": "Показывать температуру",
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
"rules.title": "Правила иконок",
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
"rules.pattern_ph": "regex, напр. розетк|plug",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Добавить правило",
"rules.reset": "Сбросить к умолчаниям",
"rules.test_ph": "Проверьте имя устройства…",
"rules.invalid": "некорректный regex",
"rules.saved": "Правила иконок сохранены",
"btn.up": "Вверх",
"btn.down": "Вниз",
"editor.tap_action": "Тап по устройству",
"tap.info": "Инфо-карточка",
"tap.more_info": "Диалог HA (more-info)",
"tap.toggle": "Переключить (свет/розетки)",
"tap.auto": "Как в настройках карточки",
"marker.tap_label": "Действие по тапу для этого устройства",
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
"import.title": "Создать пространства из этажей HA",
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
"import.start": "Создать: {n}",
"import.manual": "Начать с нуля",
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить"
}
+60
View File
@@ -112,3 +112,63 @@ export function safeUrl(url: string | null | undefined): string | null {
}
return null;
}
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**
* Domains that must NEVER toggle from a plan tap, even with an explicit
* per-device override: an accidental tap unlocking a door or disarming an
* alarm is a security incident, not a UX shortcut.
*/
export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel']);
/**
* Resolve the effective tap action for a device icon.
*
* Order: per-device override card-wide default 'info'.
* 'toggle' is applied conservatively: a card-wide toggle only affects
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
*/
export function resolveTapAction(
explicit: string | null | undefined,
cardDefault: string | null | undefined,
domain: string | null | undefined,
): TapAction {
const want = explicit || cardDefault || 'info';
if (want === 'more-info') return 'more-info';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
}
// ---------------- floors import ----------------
export interface FloorInfo {
id: string;
name: string;
level: number | null;
}
/** HA floor registry → a list ordered by level (unknown levels last), then name. */
export function floorsOf(hass: any): FloorInfo[] {
const reg = hass?.floors;
if (!reg || typeof reg !== 'object') return [];
const list: FloorInfo[] = [];
for (const f of Object.values<any>(reg)) {
if (!f || !f.floor_id) continue;
list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
}
list.sort((a, b) => {
const la = a.level ?? 1e9;
const lb = b.level ?? 1e9;
return la !== lb ? la - lb : a.name.localeCompare(b.name);
});
return list;
}
+118 -39
View File
@@ -1,53 +1,132 @@
/**
* Prototype rules ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.24.5).
* Device curation and icon rules.
*
* Icon rules are DATA, not code: the built-in defaults below can be overridden
* per instance via `config.settings.icon_rules` (edited in the card UI).
* A rule is `{ pattern, icon }`; patterns are case-insensitive regexes matched
* against "<device name> <model>", first match wins. Invalid user regexes are
* skipped silently at compile time (and flagged in the rules editor).
*/
/** Integration domains whose devices are hidden (curation). */
/** Integration domains whose devices are hidden by default (curation). */
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',
]);
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'],
];
/** Pick an MDI icon by device name/model. */
export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) {
if (pat.test(s)) return icon;
}
return 'mdi:chip';
export interface IconRule {
pattern: string;
icon: string;
}
/** Domain priority for picking the device's “primary” entity (more-info). */
/**
* Built-in defaults, bilingual (EN + RU device-name vocabularies).
* Kept as plain strings so the UI editor can display and clone them.
*/
export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'протечк|leak|water sensor', icon: 'mdi:water-alert' },
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
{ pattern: 'выключат|switch', icon: 'mdi:light-switch' },
{ pattern: 'лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip', icon: 'mdi:lightbulb' },
{ pattern: 'камер|camera', icon: 'mdi:cctv' },
{ pattern: 'замок|ttlock|lock|sn609|sn9161', icon: 'mdi:lock' },
{ pattern: 'ворота|garage|gate', icon: 'mdi:garage-variant' },
{ pattern: 'калитк|door|открыт|contact', icon: 'mdi:door' },
{ pattern: 'счётчик|счетчик|kws|meter', icon: 'mdi:meter-electric' },
{ pattern: 'вводный автомат|breaker|wifimcbn', icon: 'mdi:electric-switch' },
{ pattern: 'myheat|котёл|котел|boiler|отоплен|heating', icon: 'mdi:water-boiler' },
{ pattern: 'холодильник|fridge', icon: 'mdi:fridge' },
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' },
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
{ pattern: 'slzb|координат|zigbee|coordinator', icon: 'mdi:zigbee' },
{ pattern: 'motion|движен|presence|присутств', icon: 'mdi:motion-sensor' },
{ pattern: 'humidity|влажн', icon: 'mdi:water-percent' },
];
export interface CompiledIconRule {
re: RegExp;
icon: string;
}
/** Compile rules, silently skipping invalid regexes (user input!). */
export function compileIconRules(rules: IconRule[]): CompiledIconRule[] {
const res: CompiledIconRule[] = [];
for (const r of rules) {
if (!r || typeof r.pattern !== 'string' || !r.icon) continue;
try {
res.push({ re: new RegExp(r.pattern, 'i'), icon: r.icon });
} catch {
/* invalid user regex — skipped; the rules editor highlights it */
}
}
return res;
}
/** Whether a single rule pattern is a valid regex (for the editor UI). */
export function isValidPattern(pattern: string): boolean {
try {
new RegExp(pattern, 'i');
return true;
} catch {
return false;
}
}
const DEFAULT_COMPILED = compileIconRules(DEFAULT_ICON_RULES);
/** Fallback by entity device_class / domain when no name rule matched. */
const DEVICE_CLASS_ICONS: Record<string, string> = {
temperature: 'mdi:thermometer',
humidity: 'mdi:water-percent',
motion: 'mdi:motion-sensor',
occupancy: 'mdi:motion-sensor',
door: 'mdi:door',
window: 'mdi:window-closed',
garage_door: 'mdi:garage-variant',
smoke: 'mdi:smoke-detector',
moisture: 'mdi:water-alert',
gas: 'mdi:gas-cylinder',
power: 'mdi:meter-electric',
energy: 'mdi:meter-electric',
illuminance: 'mdi:brightness-5',
co2: 'mdi:molecule-co2',
pm25: 'mdi:air-filter',
battery: 'mdi:battery',
};
export const FALLBACK_ICON = 'mdi:chip';
/** Pick an MDI icon by device name/model using the given (or default) rules. */
export function iconFor(name?: string, model?: string, rules?: CompiledIconRule[]): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const { re, icon } of rules ?? DEFAULT_COMPILED) {
if (re.test(s)) return icon;
}
return FALLBACK_ICON;
}
/** Icon from entity device_class attributes (used when name rules gave the fallback). */
export function iconFromDeviceClasses(deviceClasses: string[]): string | null {
for (const dc of deviceClasses) {
const icon = DEVICE_CLASS_ICONS[dc];
if (icon) return icon;
}
return null;
}
/** Priority of domains for picking a device's "primary" entity (more-info). */
export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
+58 -4
View File
@@ -165,8 +165,9 @@ export const cardStyles = css`
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
background: var(--card-background-color, var(--hp-bg));
opacity: 0.92;
color: var(--hp-txt);
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
@@ -334,14 +335,14 @@ export const cardStyles = css`
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
background: var(--card-background-color, var(--hp-bg));
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;
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
}
@@ -577,6 +578,59 @@ export const cardStyles = css`
ha-icon-picker {
display: block;
}
.floorrow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
font-size: 13.5px;
cursor: pointer;
}
.floorrow .floorlvl {
color: var(--hp-muted);
font-size: 11px;
border: 1px solid var(--hp-line);
border-radius: 5px;
padding: 0 5px;
}
.importprog {
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
font-weight: 400;
}
.rhint {
font-size: 12px;
color: var(--hp-muted);
margin-bottom: 6px;
}
.rtest {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.rtest .namein { flex: 1; }
.rtest ha-icon { color: var(--hp-accent); }
.rtesticon { font-size: 11px; color: var(--hp-muted); }
.rrow {
display: flex;
align-items: center;
gap: 6px;
margin: 2px 0;
}
.rrow .rpat { flex: 2; }
.rrow .ricon { flex: 1.4; }
.rrow .rpat.bad { border-color: #ff7a5c; }
.rrow .rprev { --mdc-icon-size: 18px; color: var(--hp-txt); min-width: 18px; }
.rrow .ract {
--mdc-icon-size: 16px;
color: var(--hp-muted);
cursor: pointer;
}
.rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; }
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
+9 -1
View File
@@ -37,12 +37,18 @@ export interface Marker {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
}
export interface ServerConfig {
spaces: any[];
markers: Marker[];
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
settings: {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean;
icon_rules?: { pattern: string; icon: string }[];
};
}
export interface DevItem {
@@ -62,6 +68,7 @@ export interface DevItem {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tapAction?: string | null; // from the marker override
}
export interface CardConfig {
@@ -73,4 +80,5 @@ export interface CardConfig {
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
test('i18n: en and ru dictionaries carry the same key set', () => {
const enKeys = Object.keys(en).sort();
const ruKeys = Object.keys(ru).sort();
assert.deepEqual(enKeys, ruKeys);
});
test('i18n: no empty values', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const [k, v] of Object.entries(d)) {
assert.ok(typeof v === 'string' && v.length > 0, `${lang}:${k} is empty`);
}
}
});
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
}
});
+80 -2
View File
@@ -2,9 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl,
fitView, declump, safeUrl, resolveTapAction, floorsOf,
} from '../test-build/logic.js';
import { iconFor } from '../test-build/rules.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
test('lqiColor: boundaries and midpoint', () => {
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
@@ -128,3 +130,79 @@ test('safeUrl: allows http(s) and relative paths, cuts dangerous schemes', () =>
assert.equal(safeUrl(undefined), null);
assert.equal(safeUrl(' https://x.ru '), 'https://x.ru');
});
test('icon rules: custom rules override defaults, first match wins', () => {
const custom = compileIconRules([
{ pattern: 'plug|socket', icon: 'mdi:custom-plug' },
{ pattern: 'plug deluxe', icon: 'mdi:never-reached' },
]);
assert.equal(iconFor('Smart Plug deluxe', '', custom), 'mdi:custom-plug');
// defaults are NOT consulted when custom rules are provided
assert.equal(iconFor('Датчик протечки', '', custom), 'mdi:chip');
});
test('icon rules: invalid regex is skipped, the rest still work', () => {
const compiled = compileIconRules([
{ pattern: '[unclosed', icon: 'mdi:broken' },
{ pattern: 'camera', icon: 'mdi:cctv' },
]);
assert.equal(compiled.length, 1);
assert.equal(iconFor('Backyard camera', '', compiled), 'mdi:cctv');
});
test('icon rules: isValidPattern flags bad regexes', () => {
assert.equal(isValidPattern('plug|socket'), true);
assert.equal(isValidPattern('[unclosed'), false);
});
test('icon rules: defaults are bilingual', () => {
assert.equal(iconFor('Water leak sensor'), 'mdi:water-alert');
assert.equal(iconFor('Датчик протечки кухня'), 'mdi:water-alert');
assert.equal(iconFor('Umidifier presence sensor'), 'mdi:motion-sensor');
});
test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses(['temperature']), 'mdi:thermometer');
assert.equal(iconFromDeviceClasses(['unknown', 'motion']), 'mdi:motion-sensor');
assert.equal(iconFromDeviceClasses(['unknown']), null);
assert.equal(iconFromDeviceClasses([]), null);
});
test('tap action: defaults to info', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
});
test('tap action: card-wide toggle only touches safe domains', () => {
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
});
test('tap action: explicit per-device toggle works for cover but never for lock/alarm', () => {
assert.equal(resolveTapAction('toggle', 'info', 'cover'), 'toggle'); // conscious choice
assert.equal(resolveTapAction('toggle', 'toggle', 'lock'), 'info'); // hard security block
assert.equal(resolveTapAction('toggle', 'toggle', 'alarm_control_panel'), 'info');
assert.equal(resolveTapAction('toggle', 'info', undefined), 'info'); // no entity → nothing to toggle
});
test('tap action: per-device override beats the card default', () => {
assert.equal(resolveTapAction('info', 'toggle', 'light'), 'info');
assert.equal(resolveTapAction('more-info', 'toggle', 'light'), 'more-info');
});
test('floorsOf: sorts by level, tolerates missing registry and odd entries', () => {
assert.deepEqual(floorsOf({}), []);
assert.deepEqual(floorsOf({ floors: null }), []);
const hass = { floors: {
a: { floor_id: 'attic', name: 'Attic', level: 2 },
g: { floor_id: 'ground', name: 'Ground', level: 0 },
x: { floor_id: 'x', name: 'No level' },
bad: null,
}};
const res = floorsOf(hass);
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
});
+10 -3
View File
@@ -3,14 +3,21 @@
"target": "es2021",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2021", "dom", "dom.iterable"],
"lib": [
"es2021",
"dom",
"dom.iterable"
],
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"outDir": "tsout"
"outDir": "tsout",
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts"
]
}
+11 -2
View File
@@ -1,5 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
"include": ["src/logic.ts", "src/rules.ts"]
"compilerOptions": {
"module": "esnext",
"outDir": "test-build",
"noEmit": false,
"declaration": false,
"resolveJsonModule": true
},
"include": [
"src/logic.ts",
"src/rules.ts"
]
}