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

- floors wizard: spaces from the HA floor registry, step-by-step with mandatory
  plan upload per floor, skip support, auto-markup after the last one
- icon rules as data (settings.icon_rules) + in-card editor with live test,
  invalid-regex guard, bilingual defaults, device_class fallback chain
- tap_action (card) + per-device override; security model: card-wide toggle only
  for light/switch/fan/humidifier, explicit override for covers, never lock/alarm;
  600ms long-press always opens the info card
- i18n dictionaries → src/i18n/*.json (+parity tests); light-theme badge fixes
- frontend tests 15→28
This commit is contained in:
Matysh
2026-07-06 00:54:13 +03:00
parent 8b22fbed05
commit afa8398602
26 changed files with 1479 additions and 482 deletions
+21 -4
View File
@@ -2,7 +2,7 @@
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, DOMAIN_PRIORITY } from './rules';
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
@@ -18,6 +18,8 @@ export interface BuildCtx {
firstSpaceId: string;
/** Localized display strings for generated device names. */
loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string;
/** Compiled icon rules (instance overrides or built-in defaults). */
iconRules?: CompiledIconRule[];
}
export function entitiesByDevice(hass: any): Record<string, string[]> {
@@ -121,6 +123,18 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
return res;
}
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
const byRules = iconFor(name, model, rules);
if (byRules !== FALLBACK_ICON) return byRules;
const classes: string[] = [];
for (const eid of entIds) {
const dc = hass.states[eid]?.attributes?.device_class;
if (dc) classes.push(dc);
}
return iconFromDeviceClasses(classes) ?? FALLBACK_ICON;
}
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.name) item.name = m.name;
@@ -129,11 +143,12 @@ function applyMarker(item: DevItem, m: Marker): void {
item.link = m.link ?? null;
item.description = m.description ?? null;
item.pdfs = m.pdfs || [];
item.tapAction = m.tap_action ?? null;
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
@@ -167,7 +182,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}
const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
const key = name + '|' + area;
let icon = iconFor(name, dev.model);
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
@@ -218,7 +233,9 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const area = m.area || dev?.area_id || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const entIds = dev ? entsBy[dev.id] || [] : [];
let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle';
let icon = dev
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
: 'mdi:help-circle';
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
+14
View File
@@ -63,6 +63,19 @@ class HouseplanCardEditor extends LitElement {
},
},
},
{
name: 'tap_action',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'info', label: t(L, 'tap.info') },
{ value: 'more-info', label: t(L, 'tap.more_info') },
{ value: 'toggle', label: t(L, 'tap.toggle') },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
@@ -78,6 +91,7 @@ class HouseplanCardEditor extends LitElement {
title: t(L, 'editor.title'),
default_floor: t(L, 'editor.default_floor'),
language: t(L, 'editor.language'),
tap_action: t(L, 'editor.tap_action'),
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
+276 -11
View File
@@ -7,10 +7,13 @@
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { EXCLUDED_DOMAINS } from './rules';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
} from './rules';
import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
averageLqi, fitView, declump, safeUrl,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
} from './logic';
import { buildDevices, lqiFor, tempFor } from './devices';
import type {
@@ -20,7 +23,7 @@ import './editor';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.12.0';
const CARD_VERSION = '1.13.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -89,6 +92,13 @@ class HouseplanCard extends LitElement {
private _roViewport?: ResizeObserver;
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
private _rulesCompiledSrc = '';
private _rulesCompiled: CompiledIconRule[] | undefined;
private _infoCard: DevItem | null = null;
private _markerDialog: {
devId?: string; // the icon being edited (if any)
@@ -96,6 +106,7 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = auto
tapAction: string; // '' = card default
model: string;
link: string;
description: string;
@@ -114,6 +125,8 @@ class HouseplanCard extends LitElement {
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _holdTimer?: number;
private _holdFired = false;
static properties = {
hass: { attribute: false },
@@ -134,6 +147,8 @@ class HouseplanCard extends LitElement {
_roomDialog: { state: true },
_spaceDialog: { state: true },
_infoCard: { state: true },
_rulesDialog: { state: true },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
_view: { state: true },
@@ -146,6 +161,7 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
if (this._unsubCfg) {
@@ -307,6 +323,18 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
/** Compiled icon rules: instance settings override the built-in defaults. */
private get _iconRules(): CompiledIconRule[] | undefined {
const custom = this._settings.icon_rules;
if (!custom || !Array.isArray(custom) || !custom.length) return undefined;
const src = JSON.stringify(custom);
if (src !== this._rulesCompiledSrc) {
this._rulesCompiledSrc = src;
this._rulesCompiled = compileIconRules(custom);
}
return this._rulesCompiled;
}
private get _excluded(): Set<string> {
const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
@@ -334,10 +362,16 @@ class HouseplanCard extends LitElement {
this._loadOk &&
this._model.length === 0 &&
!this._spaceDialog &&
!this._importDialog &&
!this._onboardingShown
) {
this._onboardingShown = true;
this._openSpaceDialog('create');
const floors = floorsOf(this.hass);
if (floors.length) {
this._importDialog = { floors: floors.map((f) => ({ ...f, checked: true })) };
} else {
this._openSpaceDialog('create');
}
}
}
@@ -442,6 +476,7 @@ class HouseplanCard extends LitElement {
showAll: this._showAll,
firstSpaceId: this._model[0]?.id || '',
loc: (k) => this._t(k),
iconRules: this._iconRules,
});
this._defPos = this._defaultPositions();
}
@@ -580,8 +615,20 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick) return;
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._markup) return;
const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
if (action === 'toggle' && d.primary) {
this.hass
.callService('homeassistant', 'toggle', { entity_id: d.primary })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
return;
}
if (action === 'more-info' && d.primary) {
this._openMoreInfo(d.primary);
return;
}
this._infoCard = d;
}
@@ -785,6 +832,17 @@ class HouseplanCard extends LitElement {
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
// long-press always opens the info card — keeps metadata reachable
// even when the tap action is set to toggle
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
this._holdFired = true;
this._drag = null;
this._infoCard = d;
}
}, 600);
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
@@ -796,7 +854,10 @@ class HouseplanCard extends LitElement {
const v = this._viewOr(vb);
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) {
this._drag.moved = true;
clearTimeout(this._holdTimer);
}
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
@@ -804,6 +865,7 @@ class HouseplanCard extends LitElement {
}
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
clearTimeout(this._holdTimer);
if (!this._drag || this._drag.id !== d.id) return;
const moved = this._drag.moved;
this._drag = moved ? this._drag : null;
@@ -1103,6 +1165,7 @@ class HouseplanCard extends LitElement {
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '',
icon: d.marker?.icon || '',
tapAction: d.marker?.tap_action || '',
model: d.model || '',
link: d.link || '',
description: d.description || '',
@@ -1112,7 +1175,7 @@ class HouseplanCard extends LitElement {
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', model: '',
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
@@ -1275,6 +1338,7 @@ class HouseplanCard extends LitElement {
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
tap_action: dlg.tapAction || null,
model: dlg.model.trim() || null,
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
@@ -1451,13 +1515,18 @@ class HouseplanCard extends LitElement {
if (d.mode === 'create') this._space = sp.id;
this._regSignature = '';
this._maybeRebuildDevices();
if (wasFirst) {
if (this._importQueue.length) {
// floors-import wizard: proceed to the next floor
this._openNextImport();
} else if (wasFirst || this._importTotal > 0) {
// guide the user onward: straight into room markup mode
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._tool = 'draw';
this._path = [];
this._cursorPt = null;
this._showToast(this._t('toast.space_added_onboard'));
this._showToast(this._t(wasFirst && !this._importTotal ? 'toast.space_added_onboard' : 'import.done'));
} else {
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
}
@@ -1494,6 +1563,179 @@ class HouseplanCard extends LitElement {
}
// ================= FLOORS IMPORT WIZARD =================
private _startImport(): void {
const dlg = this._importDialog;
if (!dlg) return;
const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name);
this._importDialog = null;
if (!titles.length) {
this._openSpaceDialog('create');
return;
}
this._importQueue = titles;
this._importTotal = titles.length;
this._openNextImport();
}
/** Open the space dialog for the next queued floor (title prefilled, plan required). */
private _openNextImport(): void {
const title = this._importQueue.shift();
if (title === undefined) return;
this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, busy: false };
}
/** Skip the current floor of the wizard without creating a space. */
private _skipImport(): void {
this._spaceDialog = null;
if (this._importQueue.length) this._openNextImport();
else if (this._importTotal > 0 && this._model.length) {
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._showToast(this._t('import.done'));
}
}
private _renderImportDialog(): TemplateResult {
const d = this._importDialog!;
const n = d.floors.filter((f) => f.checked).length;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:home-floor-1"></ha-icon>${this._t('import.title')}</div>
<div class="body">
<div class="rhint">${this._t('import.hint')}</div>
${d.floors.map(
(f, i) => html`<label class="floorrow">
<input type="checkbox" .checked=${f.checked}
@change=${(e: Event) => {
const floors = [...d.floors];
floors[i] = { ...f, checked: (e.target as HTMLInputElement).checked };
this._importDialog = { floors };
}} />
<span>${f.name}</span>
${f.level != null ? html`<span class="floorlvl">L${f.level}</span>` : nothing}
</label>`,
)}
</div>
<div class="row">
<button class="btn ghost" @click=${() => { this._importDialog = null; this._openSpaceDialog('create'); }}>
${this._t('import.manual')}
</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!n}>
<ha-icon icon="mdi:import"></ha-icon>${this._t('import.start', { n })}
</button>
</div>
</div>
</div>`;
}
// ================= ICON RULES EDITOR =================
private _openRulesDialog = (): void => {
if (!this._norm) return;
const custom = this._settings.icon_rules;
const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r }));
this._rulesDialog = { rules, test: '', busy: false };
};
private _rulesSet(rules: IconRule[]): void {
this._rulesDialog = { ...this._rulesDialog!, rules };
}
private async _saveRules(): Promise<void> {
const dlg = this._rulesDialog;
if (!dlg || dlg.busy) return;
const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim());
this._rulesDialog = { ...dlg, busy: true };
try {
const cfg = this._serverCfg!;
const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES);
const settings: any = { ...cfg.settings };
if (isDefault) delete settings.icon_rules;
else settings.icon_rules = cleaned;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._rulesDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('rules.saved'));
} catch (e: any) {
this._rulesDialog = { ...this._rulesDialog!, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) }));
}
}
private _renderRulesDialog(): TemplateResult {
const d = this._rulesDialog!;
const compiled = compileIconRules(d.rules);
const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null;
const move = (i: number, delta: number) => {
const r = [...d.rules];
const j = i + delta;
if (j < 0 || j >= r.length) return;
[r[i], r[j]] = [r[j], r[i]];
this._rulesSet(r);
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('rules.title')}</div>
<div class="body">
<div class="rhint">${this._t('rules.hint')}</div>
<div class="rtest">
<input class="namein" type="text" placeholder=${this._t('rules.test_ph')}
.value=${d.test}
@input=${(e: Event) => (this._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} />
${testIcon ? html`<ha-icon icon=${testIcon}></ha-icon><span class="rtesticon">${testIcon}</span>` : nothing}
</div>
${d.rules.map((r, i) => {
const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern);
return html`<div class="rrow">
<input class="namein rpat ${bad ? 'bad' : ''}" type="text"
placeholder=${this._t('rules.pattern_ph')}
title=${bad ? this._t('rules.invalid') : ''}
.value=${r.pattern}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<input class="namein ricon" type="text" placeholder=${this._t('rules.icon_ph')}
.value=${r.icon}
@input=${(e: Event) => {
const rules = [...d.rules];
rules[i] = { ...r, icon: (e.target as HTMLInputElement).value };
this._rulesSet(rules);
}} />
<ha-icon class="rprev" icon=${r.icon || 'mdi:chip'}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-up" title=${this._t('btn.up')}
@click=${() => move(i, -1)}></ha-icon>
<ha-icon class="ract" icon="mdi:arrow-down" title=${this._t('btn.down')}
@click=${() => move(i, 1)}></ha-icon>
<ha-icon class="ract del" icon="mdi:close" title=${this._t('btn.delete')}
@click=${() => this._rulesSet(d.rules.filter((_, j) => j !== i))}></ha-icon>
</div>`;
})}
<button class="btn ghost" @click=${() => this._rulesSet([...d.rules, { pattern: '', icon: '' }])}>
<ha-icon icon="mdi:plus"></ha-icon>${this._t('rules.add')}
</button>
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
${this._t('rules.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._rulesDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveRules} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
// ================= render =================
protected render(): TemplateResult | typeof nothing {
@@ -1515,6 +1757,7 @@ class HouseplanCard extends LitElement {
: html`<p class="muted">${this._t('empty.install')}</p>`}
</div>
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>`;
}
@@ -1581,6 +1824,9 @@ class HouseplanCard extends LitElement {
</button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button>`
: nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
@@ -1637,6 +1883,8 @@ class HouseplanCard extends LitElement {
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
@@ -1837,6 +2085,14 @@ class HouseplanCard extends LitElement {
)}
</select>
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['', 'tap.auto'], ['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || '') === v}>${this._t(k as any)}</option>`,
)}
</select>
<label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
@@ -1895,7 +2151,13 @@ class HouseplanCard extends LitElement {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}</div>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
? html`<span class="importprog">${this._t('import.progress', {
i: this._importTotal - this._importQueue.length,
n: this._importTotal,
})}</span>`
: nothing}</div>
<div class="body">
<label>${this._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this._t('space.title_ph')}
@@ -1922,7 +2184,10 @@ class HouseplanCard extends LitElement {
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._spaceDialog = null)}>${this._t('btn.cancel')}</button>
${this._importTotal > 0 && d.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>${this._t('btn.skip')}</button>`
: nothing}
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSpaceDialog}
?disabled=${!d.title.trim() || !(d.planFile || d.planUrl) || d.busy}
title=${!(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
+9 -294
View File
@@ -1,306 +1,21 @@
/**
* Card UI localization. Language is resolved from the card config
* (`language: en|ru`) or, by default, from the HA user profile
* (hass.locale.language); anything that is not Russian falls back to English.
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
* languages can be contributed without touching TypeScript. The language is
* resolved from the card config (`language: en|ru`) or, by default, from the
* HA user profile (hass.locale.language); anything that is not a known
* language falls back to English.
*/
import en from './i18n/en.json';
import ru from './i18n/ru.json';
export type Lang = 'en' | 'ru';
const en = {
'card.title': 'House plan',
'count.devices': '{n} dev.',
'empty.no_spaces': 'No spaces yet.',
'empty.add_first': 'Add the first space and upload a floor plan.',
'empty.install': 'Install the House Plan integration and add it in "Devices & services".',
'btn.add_space': 'Add space',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.close': 'Close',
'btn.delete': 'Delete',
'btn.remove': 'Remove',
'btn.edit': 'Edit',
'btn.open_in_ha': 'Open in HA',
'btn.reset': 'Reset',
'btn.attach': 'Attach…',
'btn.upload': 'Upload…',
'btn.replace': 'Replace…',
'btn.no_area': 'No area',
'title.zoom_in': 'Zoom in',
'title.zoom_out': 'Zoom out',
'title.zoom_reset': 'Reset zoom',
'title.add_device': 'Add a device to the plan',
'title.show_all': 'Show all area devices (no curation)',
'title.reset_layout': 'Reset icon positions to auto layout',
'title.markup': 'Room markup: grid, lines, outlines',
'title.configure_space': 'Configure space',
'title.add_space': 'Add space',
'title.markup_add': 'Add a room: connect grid dots with lines until the outline closes',
'title.markup_erase': 'Erase a line: click the line',
'title.markup_delroom': 'Delete a room: click inside the room',
'title.no_area_room': 'Decorative room without an HA area (e.g. a hallway)',
'title.choose_area': 'Select a Home Assistant area',
'title.need_plan': 'Upload a floor-plan image',
'markup.add': 'Add',
'markup.erase': 'Erase',
'markup.delete': 'Delete',
'markup.hint_points': 'points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one',
'markup.hint_start': 'click a grid dot to start the outline',
'tip.room': 'room — open the area',
'tip.lqi': 'average zigbee signal:',
'info.device_header': 'Device on the plan',
'info.model': 'Model',
'info.state': 'State',
'info.link': 'Link',
'info.manuals': 'Manuals',
'info.none': 'No additional information',
'marker.new_device': 'New device',
'marker.name_label': 'Name (shown on the plan)',
'marker.name_ph': 'Name',
'marker.binding_label': 'Bind to an HA device',
'marker.virtual_option': 'Virtual device (no binding)',
'marker.search_ph': 'Search device / group…',
'marker.nothing_found': 'nothing found',
'marker.room_label': 'Room',
'marker.room_override': ' (override placement)',
'marker.room_choose': '— select a room —',
'marker.room_auto': '— by device area (auto) —',
'marker.icon_label': 'Icon',
'marker.icon_ph': 'mdi:… (empty = auto)',
'marker.model_label': 'Model',
'marker.model_ph': 'e.g. Aqara T&H',
'marker.link_label': 'Link',
'marker.desc_label': 'Description',
'marker.desc_ph': 'Notes, specs…',
'marker.manuals_label': 'Manuals (PDF etc.)',
'marker.sub_device': 'device',
'marker.sub_z2m_group': ' · Z2M group',
'marker.sub_group': 'group',
'marker.sub_helper': 'helper',
'space.new': 'New space',
'space.header': 'Space',
'space.title_label': 'Title',
'space.title_ph': 'e.g. Garage',
'space.plan_label': 'Floor plan (background)',
'space.no_plan': 'no plan image',
'space.plan_alt': 'plan',
'room.new': 'New room',
'room.name_label': 'Display name',
'room.name_ph': 'e.g. Terrace',
'room.area_label': 'Home Assistant area (unassigned)',
'room.no_area_option': '— no area —',
'room.default_name': 'Room',
'device.unnamed': 'unnamed',
'device.light_group': 'light group',
'device.fallback': 'device',
'device.virtual': 'virtual device',
'confirm.reset_layout': 'Reset all icon positions to the auto layout?',
'confirm.delete_room': 'Delete room "{name}"?',
'confirm.remove_marker': 'Remove "{name}" from the plan?',
'confirm.delete_space': 'Delete space "{title}" with all its rooms and markup?',
'toast.pos_save_failed': 'Failed to save position: {err}',
'toast.no_entity': 'The device has no suitable entity',
'toast.markup_needs_server': 'Markup is available after the config is moved to the server',
'toast.conflict': 'Config was changed in another window — data refreshed, repeat your last action',
'toast.cfg_save_failed': 'Failed to save config: {err}',
'toast.room_saved': 'Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.',
'toast.room_saved_no_area': 'Room saved ({n}, no area). Outline the next one or exit markup.',
'toast.marker_needs_server': 'Device editing is available after the config is moved to the server',
'toast.virtual_name_required': 'Enter a name for the virtual device',
'toast.marker_saved': 'Device saved',
'toast.marker_removed': 'Device removed from the plan',
'toast.integration_missing': 'The House Plan integration is not installed — management unavailable',
'toast.plan_formats': 'Supported formats: SVG, PNG, JPG, WebP',
'toast.plan_required': 'Upload a floor plan — it is required',
'toast.space_added_onboard': 'Space added. Outline the rooms: click grid dots and close the contour.',
'toast.space_added': 'Space added',
'toast.space_saved': 'Space saved',
'toast.space_deleted': 'Space deleted',
'toast.delete_failed': 'Delete failed: {err}',
'toast.error': 'Error: {err}',
'toast.file_failed': 'File "{name}" was not uploaded: {err}',
'toast.files_attached': 'Files attached: {n}',
'err.unknown': 'unknown error',
'err.code': 'code {code}',
'err.too_large': 'file larger than {mb} MB',
'err.bad_ext': 'unsupported type (PDF/image expected)',
'err.unauthorized': 'administrator rights required',
'editor.title': 'Title',
'editor.default_floor': 'Default space',
'editor.icon_size': 'Icon size, % of plan width',
'editor.show_temperature': 'Show temperature',
'editor.live_states': 'Live states (on/off, open…)',
'editor.show_signal': 'Show zigbee signal (LQI)',
'editor.language': 'Interface language',
'editor.lang_auto': 'Auto (HA profile)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
type Key = keyof typeof en;
const ru: Record<Key, string> = {
'card.title': 'План дома',
'count.devices': '{n} устр.',
'empty.no_spaces': 'Пространств пока нет.',
'empty.add_first': 'Добавьте первое пространство и загрузите план этажа.',
'empty.install': 'Установите интеграцию House Plan и добавьте запись в «Устройства и службы».',
'btn.add_space': 'Добавить пространство',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.close': 'Закрыть',
'btn.delete': 'Удалить',
'btn.remove': 'Убрать',
'btn.edit': 'Редактировать',
'btn.open_in_ha': 'Открыть в HA',
'btn.reset': 'Сброс',
'btn.attach': 'Прикрепить…',
'btn.upload': 'Загрузить…',
'btn.replace': 'Заменить…',
'btn.no_area': 'Без зоны',
'title.zoom_in': 'Приблизить',
'title.zoom_out': 'Отдалить',
'title.zoom_reset': 'Сбросить масштаб',
'title.add_device': 'Добавить устройство на план',
'title.show_all': 'Показывать все устройства зоны (без курирования)',
'title.reset_layout': 'Сбросить позиции значков к авто-раскладке',
'title.markup': 'Разметка комнат: сетка, линии, контуры',
'title.configure_space': 'Настроить пространство',
'title.add_space': 'Добавить пространство',
'title.markup_add': 'Добавить комнату: соединяйте точки сетки линиями до замкнутого контура',
'title.markup_erase': 'Стереть линию: клик по линии',
'title.markup_delroom': 'Удалить комнату: клик внутри комнаты',
'title.no_area_room': 'Декоративная комната без привязки к зоне (например, холл)',
'title.choose_area': 'Выберите зону Home Assistant',
'title.need_plan': 'Загрузите подложку (план этажа)',
'markup.add': 'Добавить',
'markup.erase': 'Стереть',
'markup.delete': 'Удалить',
'markup.hint_points': 'точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой',
'markup.hint_start': 'кликните точку сетки, чтобы начать контур',
'tip.room': 'комната — открыть зону',
'tip.lqi': 'средний сигнал zigbee:',
'info.device_header': 'Устройство на плане',
'info.model': 'Модель',
'info.state': 'Состояние',
'info.link': 'Ссылка',
'info.manuals': 'Инструкции',
'info.none': 'Нет дополнительной информации',
'marker.new_device': 'Новое устройство',
'marker.name_label': 'Имя (отображается на плане)',
'marker.name_ph': 'Название',
'marker.binding_label': 'Привязка к устройству HA',
'marker.virtual_option': 'Виртуальное устройство (без привязки)',
'marker.search_ph': 'Поиск устройства / группы…',
'marker.nothing_found': 'ничего не найдено',
'marker.room_label': 'Комната',
'marker.room_override': ' (переопределить размещение)',
'marker.room_choose': '— выберите комнату —',
'marker.room_auto': '— по зоне устройства (авто) —',
'marker.icon_label': 'Иконка',
'marker.icon_ph': 'mdi:… (пусто = авто)',
'marker.model_label': 'Модель',
'marker.model_ph': 'напр. Aqara T&H',
'marker.link_label': 'Ссылка',
'marker.desc_label': 'Описание',
'marker.desc_ph': 'Заметки, характеристики…',
'marker.manuals_label': 'Инструкции (PDF и т.п.)',
'marker.sub_device': 'устройство',
'marker.sub_z2m_group': ' · Z2M-группа',
'marker.sub_group': 'группа',
'marker.sub_helper': 'хелпер',
'space.new': 'Новое пространство',
'space.header': 'Пространство',
'space.title_label': 'Название',
'space.title_ph': 'Например: Гараж',
'space.plan_label': 'Подложка (план)',
'space.no_plan': 'нет подложки',
'space.plan_alt': 'план',
'room.new': 'Новая комната',
'room.name_label': 'Отображаемое имя',
'room.name_ph': 'Например: Терраса',
'room.area_label': 'Зона Home Assistant (свободные)',
'room.no_area_option': '— без зоны —',
'room.default_name': 'Комната',
'device.unnamed': 'без имени',
'device.light_group': 'группа света',
'device.fallback': 'устройство',
'device.virtual': 'виртуальное устройство',
'confirm.reset_layout': 'Сбросить позиции всех иконок к авто-раскладке?',
'confirm.delete_room': 'Удалить комнату «{name}»?',
'confirm.remove_marker': 'Убрать «{name}» с плана?',
'confirm.delete_space': 'Удалить пространство «{title}» со всеми комнатами и разметкой?',
'toast.pos_save_failed': 'Не удалось сохранить позицию: {err}',
'toast.no_entity': 'У устройства нет подходящей сущности',
'toast.markup_needs_server': 'Разметка доступна после переноса конфига на сервер',
'toast.conflict': 'Конфиг изменён в другом окне — данные обновлены, повторите последнее действие',
'toast.cfg_save_failed': 'Не удалось сохранить конфиг: {err}',
'toast.room_saved': 'Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.',
'toast.room_saved_no_area': 'Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.',
'toast.marker_needs_server': 'Редактирование устройств доступно после переноса конфига на сервер',
'toast.virtual_name_required': 'Укажите имя виртуального устройства',
'toast.marker_saved': 'Устройство сохранено',
'toast.marker_removed': 'Устройство убрано с плана',
'toast.integration_missing': 'Интеграция House Plan не установлена — управление недоступно',
'toast.plan_formats': 'Поддерживаются SVG, PNG, JPG, WebP',
'toast.plan_required': 'Загрузите подложку — план этажа обязателен',
'toast.space_added_onboard': 'Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.',
'toast.space_added': 'Пространство добавлено',
'toast.space_saved': 'Пространство сохранено',
'toast.space_deleted': 'Пространство удалено',
'toast.delete_failed': 'Ошибка удаления: {err}',
'toast.error': 'Ошибка: {err}',
'toast.file_failed': 'Файл «{name}» не загружен: {err}',
'toast.files_attached': 'Прикреплено файлов: {n}',
'err.unknown': 'неизвестная ошибка',
'err.code': 'код {code}',
'err.too_large': 'файл больше {mb} МБ',
'err.bad_ext': 'недопустимый тип (нужен PDF/изображение)',
'err.unauthorized': 'нужны права администратора',
'editor.title': 'Заголовок',
'editor.default_floor': 'Пространство по умолчанию',
'editor.icon_size': 'Размер иконок, % ширины плана',
'editor.show_temperature': 'Показывать температуру',
'editor.live_states': 'Живые состояния (вкл/выкл, открыто…)',
'editor.show_signal': 'Показывать сигнал zigbee (LQI)',
'editor.language': 'Язык интерфейса',
'editor.lang_auto': 'Авто (профиль HA)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
const DICTS: Record<Lang, Record<Key, string>> = { en, ru };
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
/** Resolve the UI language: explicit config option wins, then the HA profile. */
export function langOf(hass: any, configLang?: string | null): Lang {
if (configLang === 'ru' || configLang === 'en') return configLang;
if (configLang && configLang in DICTS) return configLang as Lang;
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
return l.startsWith('ru') ? 'ru' : 'en';
}
+155
View File
@@ -0,0 +1,155 @@
{
"card.title": "House plan",
"count.devices": "{n} dev.",
"empty.no_spaces": "No spaces yet.",
"empty.add_first": "Add the first space and upload a floor plan.",
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
"btn.add_space": "Add space",
"btn.cancel": "Cancel",
"btn.save": "Save",
"btn.close": "Close",
"btn.delete": "Delete",
"btn.remove": "Remove",
"btn.edit": "Edit",
"btn.open_in_ha": "Open in HA",
"btn.reset": "Reset",
"btn.attach": "Attach…",
"btn.upload": "Upload…",
"btn.replace": "Replace…",
"btn.no_area": "No area",
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no curation)",
"title.reset_layout": "Reset icon positions to auto layout",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
"title.markup_erase": "Erase a line: click the line",
"title.markup_delroom": "Delete a room: click inside the room",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Add",
"markup.erase": "Erase",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
"tip.room": "room — open the area",
"tip.lqi": "average zigbee signal:",
"info.device_header": "Device on the plan",
"info.model": "Model",
"info.state": "State",
"info.link": "Link",
"info.manuals": "Manuals",
"info.none": "No additional information",
"marker.new_device": "New device",
"marker.name_label": "Name (shown on the plan)",
"marker.name_ph": "Name",
"marker.binding_label": "Bind to an HA device",
"marker.virtual_option": "Virtual device (no binding)",
"marker.search_ph": "Search device / group…",
"marker.nothing_found": "nothing found",
"marker.room_label": "Room",
"marker.room_override": " (override placement)",
"marker.room_choose": "— select a room —",
"marker.room_auto": "— by device area (auto) —",
"marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)",
"marker.model_label": "Model",
"marker.model_ph": "e.g. Aqara T&H",
"marker.link_label": "Link",
"marker.desc_label": "Description",
"marker.desc_ph": "Notes, specs…",
"marker.manuals_label": "Manuals (PDF etc.)",
"marker.sub_device": "device",
"marker.sub_z2m_group": " · Z2M group",
"marker.sub_group": "group",
"marker.sub_helper": "helper",
"space.new": "New space",
"space.header": "Space",
"space.title_label": "Title",
"space.title_ph": "e.g. Garage",
"space.plan_label": "Floor plan (background)",
"space.no_plan": "no plan image",
"space.plan_alt": "plan",
"room.new": "New room",
"room.name_label": "Display name",
"room.name_ph": "e.g. Terrace",
"room.area_label": "Home Assistant area (unassigned)",
"room.no_area_option": "— no area —",
"room.default_name": "Room",
"device.unnamed": "unnamed",
"device.light_group": "light group",
"device.fallback": "device",
"device.virtual": "virtual device",
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
"confirm.delete_room": "Delete room \"{name}\"?",
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
"toast.pos_save_failed": "Failed to save position: {err}",
"toast.no_entity": "The device has no suitable entity",
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
"toast.virtual_name_required": "Enter a name for the virtual device",
"toast.marker_saved": "Device saved",
"toast.marker_removed": "Device removed from the plan",
"toast.integration_missing": "The House Plan integration is not installed — management unavailable",
"toast.plan_formats": "Supported formats: SVG, PNG, JPG, WebP",
"toast.plan_required": "Upload a floor plan — it is required",
"toast.space_added_onboard": "Space added. Outline the rooms: click grid dots and close the contour.",
"toast.space_added": "Space added",
"toast.space_saved": "Space saved",
"toast.space_deleted": "Space deleted",
"toast.delete_failed": "Delete failed: {err}",
"toast.error": "Error: {err}",
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
"toast.files_attached": "Files attached: {n}",
"err.unknown": "unknown error",
"err.code": "code {code}",
"err.too_large": "file larger than {mb} MB",
"err.bad_ext": "unsupported type (PDF/image expected)",
"err.unauthorized": "administrator rights required",
"editor.title": "Title",
"editor.default_floor": "Default space",
"editor.icon_size": "Icon size, % of plan width",
"editor.show_temperature": "Show temperature",
"editor.live_states": "Live states (on/off, open…)",
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
"rules.title": "Icon rules",
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
"rules.pattern_ph": "regex, e.g. plug|socket",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Add rule",
"rules.reset": "Reset to defaults",
"rules.test_ph": "Try a device name…",
"rules.invalid": "invalid regex",
"rules.saved": "Icon rules saved",
"btn.up": "Up",
"btn.down": "Down",
"editor.tap_action": "Tap on a device",
"tap.info": "Info card",
"tap.more_info": "HA more-info dialog",
"tap.toggle": "Toggle (lights/switches)",
"tap.auto": "As the card default",
"marker.tap_label": "Tap action for this device",
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
"import.title": "Create spaces from HA floors",
"import.hint": "Your Home Assistant already knows these floors. Pick the ones to turn into plan spaces — you will upload a floor-plan image for each one next. Rooms are then outlined by hand on the plan.",
"import.start": "Create {n} space(s)",
"import.manual": "Start from scratch",
"import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip"
}
+155
View File
@@ -0,0 +1,155 @@
{
"card.title": "План дома",
"count.devices": "{n} устр.",
"empty.no_spaces": "Пространств пока нет.",
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
"btn.add_space": "Добавить пространство",
"btn.cancel": "Отмена",
"btn.save": "Сохранить",
"btn.close": "Закрыть",
"btn.delete": "Удалить",
"btn.remove": "Убрать",
"btn.edit": "Редактировать",
"btn.open_in_ha": "Открыть в HA",
"btn.reset": "Сброс",
"btn.attach": "Прикрепить…",
"btn.upload": "Загрузить…",
"btn.replace": "Заменить…",
"btn.no_area": "Без зоны",
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)",
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_erase": "Стереть линию: клик по линии",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"markup.erase": "Стереть",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
"tip.room": "комната — открыть зону",
"tip.lqi": "средний сигнал zigbee:",
"info.device_header": "Устройство на плане",
"info.model": "Модель",
"info.state": "Состояние",
"info.link": "Ссылка",
"info.manuals": "Инструкции",
"info.none": "Нет дополнительной информации",
"marker.new_device": "Новое устройство",
"marker.name_label": "Имя (отображается на плане)",
"marker.name_ph": "Название",
"marker.binding_label": "Привязка к устройству HA",
"marker.virtual_option": "Виртуальное устройство (без привязки)",
"marker.search_ph": "Поиск устройства / группы…",
"marker.nothing_found": "ничего не найдено",
"marker.room_label": "Комната",
"marker.room_override": " (переопределить размещение)",
"marker.room_choose": "— выберите комнату —",
"marker.room_auto": "— по зоне устройства (авто) —",
"marker.icon_label": "Иконка",
"marker.icon_ph": "mdi:… (пусто = авто)",
"marker.model_label": "Модель",
"marker.model_ph": "напр. Aqara T&H",
"marker.link_label": "Ссылка",
"marker.desc_label": "Описание",
"marker.desc_ph": "Заметки, характеристики…",
"marker.manuals_label": "Инструкции (PDF и т.п.)",
"marker.sub_device": "устройство",
"marker.sub_z2m_group": " · Z2M-группа",
"marker.sub_group": "группа",
"marker.sub_helper": "хелпер",
"space.new": "Новое пространство",
"space.header": "Пространство",
"space.title_label": "Название",
"space.title_ph": "Например: Гараж",
"space.plan_label": "Подложка (план)",
"space.no_plan": "нет подложки",
"space.plan_alt": "план",
"room.new": "Новая комната",
"room.name_label": "Отображаемое имя",
"room.name_ph": "Например: Терраса",
"room.area_label": "Зона Home Assistant (свободные)",
"room.no_area_option": "— без зоны —",
"room.default_name": "Комната",
"device.unnamed": "без имени",
"device.light_group": "группа света",
"device.fallback": "устройство",
"device.virtual": "виртуальное устройство",
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
"toast.no_entity": "У устройства нет подходящей сущности",
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
"toast.virtual_name_required": "Укажите имя виртуального устройства",
"toast.marker_saved": "Устройство сохранено",
"toast.marker_removed": "Устройство убрано с плана",
"toast.integration_missing": "Интеграция House Plan не установлена — управление недоступно",
"toast.plan_formats": "Поддерживаются SVG, PNG, JPG, WebP",
"toast.plan_required": "Загрузите подложку — план этажа обязателен",
"toast.space_added_onboard": "Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"toast.space_added": "Пространство добавлено",
"toast.space_saved": "Пространство сохранено",
"toast.space_deleted": "Пространство удалено",
"toast.delete_failed": "Ошибка удаления: {err}",
"toast.error": "Ошибка: {err}",
"toast.file_failed": "Файл «{name}» не загружен: {err}",
"toast.files_attached": "Прикреплено файлов: {n}",
"err.unknown": "неизвестная ошибка",
"err.code": "код {code}",
"err.too_large": "файл больше {mb} МБ",
"err.bad_ext": "недопустимый тип (нужен PDF/изображение)",
"err.unauthorized": "нужны права администратора",
"editor.title": "Заголовок",
"editor.default_floor": "Пространство по умолчанию",
"editor.icon_size": "Размер иконок, % ширины плана",
"editor.show_temperature": "Показывать температуру",
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
"rules.title": "Правила иконок",
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
"rules.pattern_ph": "regex, напр. розетк|plug",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Добавить правило",
"rules.reset": "Сбросить к умолчаниям",
"rules.test_ph": "Проверьте имя устройства…",
"rules.invalid": "некорректный regex",
"rules.saved": "Правила иконок сохранены",
"btn.up": "Вверх",
"btn.down": "Вниз",
"editor.tap_action": "Тап по устройству",
"tap.info": "Инфо-карточка",
"tap.more_info": "Диалог HA (more-info)",
"tap.toggle": "Переключить (свет/розетки)",
"tap.auto": "Как в настройках карточки",
"marker.tap_label": "Действие по тапу для этого устройства",
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
"import.title": "Создать пространства из этажей HA",
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
"import.start": "Создать: {n}",
"import.manual": "Начать с нуля",
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить"
}
+60
View File
@@ -112,3 +112,63 @@ export function safeUrl(url: string | null | undefined): string | null {
}
return null;
}
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**
* Domains that must NEVER toggle from a plan tap, even with an explicit
* per-device override: an accidental tap unlocking a door or disarming an
* alarm is a security incident, not a UX shortcut.
*/
export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel']);
/**
* Resolve the effective tap action for a device icon.
*
* Order: per-device override card-wide default 'info'.
* 'toggle' is applied conservatively: a card-wide toggle only affects
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
*/
export function resolveTapAction(
explicit: string | null | undefined,
cardDefault: string | null | undefined,
domain: string | null | undefined,
): TapAction {
const want = explicit || cardDefault || 'info';
if (want === 'more-info') return 'more-info';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
}
// ---------------- floors import ----------------
export interface FloorInfo {
id: string;
name: string;
level: number | null;
}
/** HA floor registry → a list ordered by level (unknown levels last), then name. */
export function floorsOf(hass: any): FloorInfo[] {
const reg = hass?.floors;
if (!reg || typeof reg !== 'object') return [];
const list: FloorInfo[] = [];
for (const f of Object.values<any>(reg)) {
if (!f || !f.floor_id) continue;
list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
}
list.sort((a, b) => {
const la = a.level ?? 1e9;
const lb = b.level ?? 1e9;
return la !== lb ? la - lb : a.name.localeCompare(b.name);
});
return list;
}
+118 -39
View File
@@ -1,53 +1,132 @@
/**
* Prototype rules ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.24.5).
* Device curation and icon rules.
*
* Icon rules are DATA, not code: the built-in defaults below can be overridden
* per instance via `config.settings.icon_rules` (edited in the card UI).
* A rule is `{ pattern, icon }`; patterns are case-insensitive regexes matched
* against "<device name> <model>", first match wins. Invalid user regexes are
* skipped silently at compile time (and flagged in the rules editor).
*/
/** Integration domains whose devices are hidden (curation). */
/** Integration domains whose devices are hidden by default (curation). */
export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
'upnp_serial_number',
]);
const ICON_RULES: Array<[RegExp, string]> = [
[/протечк/, 'mdi:water-alert'],
[/клапан/, 'mdi:pipe-valve'],
[/дым/, 'mdi:smoke-detector'],
[/термоголов/, 'mdi:radiator'],
[/температ/, 'mdi:thermometer'],
[/qingping|air monitor|молекул/, 'mdi:air-filter'],
[/штор/, 'mdi:roller-shade'],
[/розетк|plug/, 'mdi:power-socket-de'],
[/выключат|switch/, 'mdi:light-switch'],
[/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
[/камер|camera/, 'mdi:cctv'],
[/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
[/ворота|garage/, 'mdi:garage-variant'],
[/калитк|door|открыт/, 'mdi:door'],
[/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
[/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
[/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
[/холодильник|fridge/, 'mdi:fridge'],
[/стиральн|washer/, 'mdi:washing-machine'],
[/сушилк|dryer/, 'mdi:tumble-dryer'],
[/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
[/soundbar|колонк|станц/, 'mdi:soundbar'],
[/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
[/keenetic|роутер|router/, 'mdi:router-wireless'],
[/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
[/slzb|координат|zigbee/, 'mdi:zigbee'],
];
/** Pick an MDI icon by device name/model. */
export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) {
if (pat.test(s)) return icon;
}
return 'mdi:chip';
export interface IconRule {
pattern: string;
icon: string;
}
/** Domain priority for picking the device's “primary” entity (more-info). */
/**
* Built-in defaults, bilingual (EN + RU device-name vocabularies).
* Kept as plain strings so the UI editor can display and clone them.
*/
export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'протечк|leak|water sensor', icon: 'mdi:water-alert' },
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
{ pattern: 'выключат|switch', icon: 'mdi:light-switch' },
{ pattern: 'лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip', icon: 'mdi:lightbulb' },
{ pattern: 'камер|camera', icon: 'mdi:cctv' },
{ pattern: 'замок|ttlock|lock|sn609|sn9161', icon: 'mdi:lock' },
{ pattern: 'ворота|garage|gate', icon: 'mdi:garage-variant' },
{ pattern: 'калитк|door|открыт|contact', icon: 'mdi:door' },
{ pattern: 'счётчик|счетчик|kws|meter', icon: 'mdi:meter-electric' },
{ pattern: 'вводный автомат|breaker|wifimcbn', icon: 'mdi:electric-switch' },
{ pattern: 'myheat|котёл|котел|boiler|отоплен|heating', icon: 'mdi:water-boiler' },
{ pattern: 'холодильник|fridge', icon: 'mdi:fridge' },
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' },
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
{ pattern: 'slzb|координат|zigbee|coordinator', icon: 'mdi:zigbee' },
{ pattern: 'motion|движен|presence|присутств', icon: 'mdi:motion-sensor' },
{ pattern: 'humidity|влажн', icon: 'mdi:water-percent' },
];
export interface CompiledIconRule {
re: RegExp;
icon: string;
}
/** Compile rules, silently skipping invalid regexes (user input!). */
export function compileIconRules(rules: IconRule[]): CompiledIconRule[] {
const res: CompiledIconRule[] = [];
for (const r of rules) {
if (!r || typeof r.pattern !== 'string' || !r.icon) continue;
try {
res.push({ re: new RegExp(r.pattern, 'i'), icon: r.icon });
} catch {
/* invalid user regex — skipped; the rules editor highlights it */
}
}
return res;
}
/** Whether a single rule pattern is a valid regex (for the editor UI). */
export function isValidPattern(pattern: string): boolean {
try {
new RegExp(pattern, 'i');
return true;
} catch {
return false;
}
}
const DEFAULT_COMPILED = compileIconRules(DEFAULT_ICON_RULES);
/** Fallback by entity device_class / domain when no name rule matched. */
const DEVICE_CLASS_ICONS: Record<string, string> = {
temperature: 'mdi:thermometer',
humidity: 'mdi:water-percent',
motion: 'mdi:motion-sensor',
occupancy: 'mdi:motion-sensor',
door: 'mdi:door',
window: 'mdi:window-closed',
garage_door: 'mdi:garage-variant',
smoke: 'mdi:smoke-detector',
moisture: 'mdi:water-alert',
gas: 'mdi:gas-cylinder',
power: 'mdi:meter-electric',
energy: 'mdi:meter-electric',
illuminance: 'mdi:brightness-5',
co2: 'mdi:molecule-co2',
pm25: 'mdi:air-filter',
battery: 'mdi:battery',
};
export const FALLBACK_ICON = 'mdi:chip';
/** Pick an MDI icon by device name/model using the given (or default) rules. */
export function iconFor(name?: string, model?: string, rules?: CompiledIconRule[]): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const { re, icon } of rules ?? DEFAULT_COMPILED) {
if (re.test(s)) return icon;
}
return FALLBACK_ICON;
}
/** Icon from entity device_class attributes (used when name rules gave the fallback). */
export function iconFromDeviceClasses(deviceClasses: string[]): string | null {
for (const dc of deviceClasses) {
const icon = DEVICE_CLASS_ICONS[dc];
if (icon) return icon;
}
return null;
}
/** Priority of domains for picking a device's "primary" entity (more-info). */
export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
+58 -4
View File
@@ -165,8 +165,9 @@ export const cardStyles = css`
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
background: var(--card-background-color, var(--hp-bg));
opacity: 0.92;
color: var(--hp-txt);
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
@@ -334,14 +335,14 @@ export const cardStyles = css`
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: #dff1ff;
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
}
@@ -577,6 +578,59 @@ export const cardStyles = css`
ha-icon-picker {
display: block;
}
.floorrow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
font-size: 13.5px;
cursor: pointer;
}
.floorrow .floorlvl {
color: var(--hp-muted);
font-size: 11px;
border: 1px solid var(--hp-line);
border-radius: 5px;
padding: 0 5px;
}
.importprog {
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
font-weight: 400;
}
.rhint {
font-size: 12px;
color: var(--hp-muted);
margin-bottom: 6px;
}
.rtest {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.rtest .namein { flex: 1; }
.rtest ha-icon { color: var(--hp-accent); }
.rtesticon { font-size: 11px; color: var(--hp-muted); }
.rrow {
display: flex;
align-items: center;
gap: 6px;
margin: 2px 0;
}
.rrow .rpat { flex: 2; }
.rrow .ricon { flex: 1.4; }
.rrow .rpat.bad { border-color: #ff7a5c; }
.rrow .rprev { --mdc-icon-size: 18px; color: var(--hp-txt); min-width: 18px; }
.rrow .ract {
--mdc-icon-size: 16px;
color: var(--hp-muted);
cursor: pointer;
}
.rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; }
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
+9 -1
View File
@@ -37,12 +37,18 @@ export interface Marker {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
}
export interface ServerConfig {
spaces: any[];
markers: Marker[];
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
settings: {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean;
icon_rules?: { pattern: string; icon: string }[];
};
}
export interface DevItem {
@@ -62,6 +68,7 @@ export interface DevItem {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tapAction?: string | null; // from the marker override
}
export interface CardConfig {
@@ -73,4 +80,5 @@ export interface CardConfig {
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
}