mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-30 08:46:03 +00:00
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:
@@ -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).
|
||||
|
||||

|
||||
|
||||
@@ -125,6 +127,22 @@ Device icons can be **dragged with the mouse at any time** — no separate "edit
|
||||
|
||||

|
||||
|
||||
### 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**.
|
||||
|
||||
@@ -85,6 +85,10 @@ title: План дома
|
||||
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
@@ -125,6 +129,24 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
### Управление с плана (tap actions)
|
||||
|
||||
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
|
||||
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
|
||||
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
|
||||
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
|
||||
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
|
||||
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
|
||||
нажатие** всегда открывает инфо-карточку.
|
||||
|
||||
### Правила иконок
|
||||
|
||||
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
|
||||
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
|
||||
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
|
||||
правило не подошло — решает *device class* сущности (термометр для датчиков
|
||||
температуры и т.п.).
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
],
|
||||
|
||||
Vendored
+189
-56
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+24
-2
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Пропустить"
|
||||
}
|
||||
@@ -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
@@ -1,53 +1,132 @@
|
||||
/**
|
||||
* Prototype rules — ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.2–4.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
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user