feat: add configurable summary panel (#437)

Issue: #437
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-08 18:49:36 +03:00
parent 7dbb6c8e72
commit 96e07b9a02
77 changed files with 3980 additions and 425 deletions
+3
View File
@@ -23,6 +23,7 @@ export interface HpConfigSnapshot {
rev: number;
/** Runtime-only: absent until a fresh config/get advertises exact v1. */
decorAssetsApi: number | null;
summaryPanelApi: number | null;
configFingerprint: string;
layout: Record<string, any>;
layoutRev: number;
@@ -53,6 +54,7 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
config: c.config,
rev: c.rev || 0,
decorAssetsApi: null,
summaryPanelApi: null,
configFingerprint: c.config_fingerprint || contentFingerprint(c.config),
layout,
layoutRev: c.layout_rev || 0,
@@ -84,6 +86,7 @@ async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapsh
config: cfgResp?.config ?? null,
rev: configRev,
decorAssetsApi: cfgResp?.decor_assets_api === 1 ? 1 : null,
summaryPanelApi: cfgResp?.summary_panel_api === 1 ? 1 : null,
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
layout: layResp?.layout ?? {},
layoutRev: layResp?.rev ?? 0,
+21 -21
View File
@@ -894,7 +894,7 @@ export class HouseplanCard extends LitElement {
this._setMode(mode, animate);
}
public hass?: any;
public panelHost = false;
public panelHost = false; public narrow: boolean | null = null;
private _config?: CardConfig;
private _space = 'f1';
@@ -2139,7 +2139,7 @@ export class HouseplanCard extends LitElement {
/** #423: support protocol capability from config/get; never persisted. */
private _haSupportApi: number | null = null;
/** #51: custom-image protocol capability from config/get; fail closed. */
private _haDecorAssetsApi: number | null = null;
private _haDecorAssetsApi: number | null = null; private _haSummaryPanelApi: number | null = null;
private _decorAssetSyncToken = 0;
private _alignDialog: {
report: OptimizeReport; config: any; layout: Record<string, any>;
@@ -2289,8 +2289,8 @@ export class HouseplanCard extends LitElement {
private _viewPreference: Record<string, 'flat' | 'iso'> = {};
private _renderProjection: 'flat' | 'iso' = 'flat';
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 }; private _kioskDialog = false;
private _summary?: import('./summary-panel-runtime-loaded').LoadedSummaryPanelRuntime;
/**
* Previous entity states + the short event/terminal-transition window for
* every marker. The map lives outside Lit state: hass ticks update it, one
@@ -2344,7 +2344,7 @@ export class HouseplanCard extends LitElement {
private _pageVisibility = (signal: PageVisibilitySignal): void => {
this._continuity.visibility(signal);
this._dayCycleVisibility(signal);
this._dayCycleVisibility(signal); this._summary?.visibility(signal.kind);
if (signal.kind === 'hidden') {
this._clearRoomFocus(true);
this._clearTransientHover(true);
@@ -2517,6 +2517,7 @@ export class HouseplanCard extends LitElement {
_dangerConfirm: { state: true },
hass: { attribute: false },
panelHost: { type: Boolean, attribute: 'panel-host', reflect: true },
narrow: { attribute: false },
_config: { state: true },
_space: { state: true },
_layout: { state: true },
@@ -2617,6 +2618,7 @@ export class HouseplanCard extends LitElement {
this._showToast(this._t('toast.locale_load_failed'));
}), subscribeFurnitureArtLoadFailures(() => this._showToast(this._t('toast.furniture_art_load_failed')))); // #474
super.connectedCallback();
if (this._summary) this._summary.connect(); else void import('./summary-panel-runtime-loaded').then(({ LoadedSummaryPanelRuntime }) => { this._summary ||= new LoadedSummaryPanelRuntime(this); if (this.isConnected) this._summary.connect(); this.requestUpdate(); }).catch(() => undefined);
void this._ensureLiveRuntime().catch(() => this.requestUpdate());
this._pointerModality.connect(this.ownerDocument.defaultView);
const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver;
@@ -2709,7 +2711,7 @@ export class HouseplanCard extends LitElement {
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; }
this._warmModeRequest = 0;
if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; }
if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; } this._summary?.disconnect();
this._dayCycleClockKey = '';
if (this._bootSettleRaf) { cancelAnimationFrame(this._bootSettleRaf); this._bootSettleRaf = 0; }
this._bootSettling = false;
@@ -2857,6 +2859,7 @@ export class HouseplanCard extends LitElement {
if (this._pdfDialog) { this._pdfDialog = false; return; }
if (this._supportDialog) { void this._editorRuntime?._closeSupportDialog(); return; }
if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._summary?.closeDialogIfIdle()) return;
if (this._markerDialog) { this._closeMarkerDialog(); return; }
if (this._deviceInbox) { this._deviceInbox = null; return; }
if (this._openingDialog) { this._openingDialog = null; return; }
@@ -4111,7 +4114,7 @@ export class HouseplanCard extends LitElement {
this._captureRenderDeviceSnapshot();
}
protected updated(): void {
this._liveRt?.commit();
this._summary?.updated(); this._liveRt?.commit();
this._editorRuntime?._commitLiveEditor();
this._pruneDevicePressFeedback();
this._syncDayCycleClock();
@@ -4120,7 +4123,7 @@ export class HouseplanCard extends LitElement {
if (this._editorRuntime) this._dtMeasure();
const stage = this._stageEl;
if (stage && !this._roViewport) {
this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport = new ResizeObserver(() => { this._refitView(); requestAnimationFrame(() => this._summary?.resized()); });
this._roViewport.observe(stage);
}
if (stage && this._booting && !this._bootTimer) this._bootWatch();
@@ -6203,8 +6206,8 @@ export class HouseplanCard extends LitElement {
wallSilhouettes: structural.wallSilhouettes,
resolveCollisions,
iconPct, deviceBasePct, showLqi, cellCm: this._cellCm,
kioskIconScale: this._kiosk ? this._kioskScale.icon : 1,
kioskFontScale: this._kiosk ? this._kioskScale.font : 1,
kioskIconScale: this._mode === 'view' ? this._kioskScale.icon : 1,
kioskFontScale: this._mode === 'view' ? this._kioskScale.font : 1,
stageSize: this._stageEl?.getBoundingClientRect?.(),
selectedDeviceId: this._selId,
focusedRoomId: this._roomFocus?.roomId,
@@ -9029,7 +9032,7 @@ export class HouseplanCard extends LitElement {
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog || this._deviceInbox
|| this._infoCard || this._rulesDialog || this._settingsDialog || this._supportDialog
|| this._alignDialog || this._importDialog || this._kioskDialog
|| this._alignDialog || this._importDialog || this._kioskDialog || this._summary?.blocksOtherDialogs()
|| this._backupExportDialog || this._backupImportDialog
|| this._wallDialog);
}
@@ -11157,15 +11160,7 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._renderRulesDialog();
}
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
this._kioskScale = { ...this._kioskScale, ...patch };
try {
localStorage.setItem(LS_KIOSK, JSON.stringify(this._kioskScale));
} catch {
/* ignore */
}
this.requestUpdate();
}
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void { this._summary?.saveScale(patch); }
/** Per-SCREEN size settings (wall tablets differ) — stored locally. */
private _renderKioskDialog(): TemplateResult {
@@ -11480,6 +11475,7 @@ export class HouseplanCard extends LitElement {
<ha-icon icon=${iso ? 'mdi:view-grid-outline' : 'mdi:cube-outline'}></ha-icon>
</button>`
: nothing}
${!this._kiosk ? this._summary?.renderControls(false) : nothing}
<div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
${''/* docs/CANVAS.md §8: this IS «вписать всё» — the old "reset
@@ -11809,7 +11805,7 @@ export class HouseplanCard extends LitElement {
space-card, so the two renderers agree. The compatibility
base is resolved before `iconCqw`; only the per-device and
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" data-hp-live-layer="camera" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
<div class="devlayer" data-hp-live-layer="camera" style="--icon-size:${iconCqw(iconPct, space, view.w, this._mode === 'view' ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._mode === 'view' ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._mode === 'view' ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._mode === 'view' ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(
d, view, showLqi, isoOverlays?.devices.get(d.id),
))}
@@ -11834,6 +11830,9 @@ export class HouseplanCard extends LitElement {
${this._renderHomeArrow(this._baseVb(projection, isoScene))}
${this._renderEditorRuntimeLoading()}
${this._renderRecoveryOverlay()}
${this._summary?.renderMeasure()}
${this._kiosk ? this._summary?.renderControls(true) : nothing}
${this._summary?.renderPanel()}
${this._booting || this._bootFading
? html`<div class="bootveil ${this._booting ? '' : 'off'}" aria-hidden="true">
<svg class="boothouse" viewBox="0 0 24 24"><path d="${mdiHomeCityOutline}"></path></svg>
@@ -11897,6 +11896,7 @@ export class HouseplanCard extends LitElement {
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._summary?.dialogOpen ? this._summary.renderDialog() : nothing}
${this._vacFit ? html`<div class="vaccalbar" aria-busy=${String(!!this._vacFit.busy)}>
<span>${this._t('vac.fit_hint')}</span>
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
+3
View File
@@ -3,6 +3,7 @@ import './houseplan-card';
interface HouseplanCardElement extends HTMLElement {
hass?: unknown;
panelHost: boolean;
narrow: boolean | null;
setConfig(config: { type: 'custom:houseplan-card' }): void;
}
@@ -60,6 +61,7 @@ export class HouseplanPanel extends HTMLElement {
public set narrow(value: boolean) {
this._narrow = Boolean(value);
this.toggleAttribute('narrow', this._narrow);
if (this._card) this._card.narrow = this._narrow;
}
public get route(): unknown { return this._route; }
@@ -200,6 +202,7 @@ export class HouseplanPanel extends HTMLElement {
content.className = 'content';
const card = document.createElement('houseplan-card') as HouseplanCardElement;
card.panelHost = true;
card.narrow = this._narrow;
card.setConfig({ type: 'custom:houseplan-card' });
if (this._hass !== undefined) card.hass = this._hass;
content.append(card);
+5 -2
View File
@@ -1,7 +1,7 @@
/** Editor chrome: toolbars, tabs, menus and hints (#266, split from styles.ts). */
import { css } from 'lit';
import { css, type CSSResultGroup } from 'lit';
export const chromeStyles = css`
const chromeCoreStyles = css`
.hdr {
position: sticky;
top: var(--header-height, 56px);
@@ -389,3 +389,6 @@ export const chromeStyles = css`
display: block;
}
`;
/** #437 summary CSS lives with its lazy runtime in summary-panel-style.ts. */
export const chromeStyles: CSSResultGroup = chromeCoreStyles;
+2
View File
@@ -0,0 +1,2 @@
/** Backend capability discriminator for the shared summary-panel schema. */
export const SUMMARY_PANEL_API_VERSION = 1;
+262
View File
@@ -0,0 +1,262 @@
import { html, nothing, type TemplateResult } from 'lit';
import type { SummaryPanelConfig, SummaryPanelSource } from './types';
import {
type SummaryDraftProblem, type SummaryPanelLocalPreferences,
moveSummaryItem, newSummaryBlock, newSummaryValue,
normalizeSummaryDraft, sameSummaryPanel,
} from './summary-panel';
import type { SummaryPanelDialogState } from './summary-panel-runtime-loaded';
import type { SummaryPanelHost } from './summary-panel-host';
export type SummaryPanelEditorContext = {
host: SummaryPanelHost;
dialog: SummaryPanelDialogState;
local: SummaryPanelLocalPreferences;
storageUnavailable: boolean;
problems: SummaryDraftProblem[];
setDialog(dialog: SummaryPanelDialogState | null): void;
saveLocal(patch: Partial<SummaryPanelLocalPreferences>): void;
mutate(mutate: (draft: SummaryPanelConfig) => void): void;
deleteBlock(index: number): void;
dragStart(event: DragEvent, token: string): void;
drop(event: DragEvent, target: string): void;
sourceToken(source: SummaryPanelSource): string;
setSource(blockIndex: number, valueIndex: number, token: string): void;
save(): void;
reload(): void;
close(): void;
t(key: string): string;
};
export type SummaryPanelEditorRenderer = (
context: SummaryPanelEditorContext,
) => TemplateResult | typeof nothing;
/** Lazy settings surface for #437; imported only after the gear is pressed. */
export const renderSummaryPanelEditor: SummaryPanelEditorRenderer = (context) => {
const { host, dialog, local, problems, t } = context;
const firstError = problems.find((problem) => problem.kind === 'error');
const changed = dialog.localShow !== dialog.baseLocalShow
|| !dialog.localOnly && !sameSummaryPanel(normalizeSummaryDraft(dialog.draft), dialog.base);
const canSave = changed && !dialog.busy && !firstError;
const fieldProblem = (path: string) => problems.find((problem) => problem.path === path);
const problemText = (problem: SummaryDraftProblem | undefined) => problem
? t(`summary.problem.${problem.code}`) : '';
const allEntities = Object.entries(host.hass?.states || {}).sort((a, b) => {
const an = String(a[1]?.attributes?.friendly_name || a[0]);
const bn = String(b[1]?.attributes?.friendly_name || b[0]);
return an.localeCompare(bn);
});
const query = dialog.entityFilter.trim().toLocaleLowerCase();
const entities = query ? allEntities.filter(([id, state]) => `${id} ${
state.attributes?.friendly_name || ''}`.toLocaleLowerCase().includes(query)) : allEntities;
const close = () => context.close();
const showRow = html`<label class="summary-switch"><input type="checkbox"
.checked=${dialog.localShow}
@change=${(event: Event) => context.setDialog({
...dialog, localShow: (event.target as HTMLInputElement).checked,
})} />
<span>${t('summary.show_local')}</span></label>`;
const sizeRows = html`<h3 class="summary-sizes-title">${t('summary.sizes_title')}</h3>
<div class="summary-local-sizes">
<label>${t('kiosk.icon_scale')}</label>
<input type="range" min="50" max="300" step="5"
.value=${String(Math.round(local.icon_scale * 100))}
@input=${(event: Event) => context.saveLocal({
icon_scale: Number((event.target as HTMLInputElement).value) / 100,
})} />
<span>${Math.round(local.icon_scale * 100)}%</span>
<label>${t('kiosk.font_scale')}</label>
<input type="range" min="50" max="300" step="5"
.value=${String(Math.round(local.font_scale * 100))}
@input=${(event: Event) => context.saveLocal({
font_scale: Number((event.target as HTMLInputElement).value) / 100,
})} />
<span>${Math.round(local.font_scale * 100)}%</span>
<button class="btn ghost summary-size-reset" type="button"
@click=${() => context.saveLocal({ icon_scale: 1, font_scale: 1 })}>${t('gs.reset')}</button>
</div>
${context.storageUnavailable
? html`<div class="summary-problem warning">${t('summary.storage_unavailable')}</div>` : nothing}`;
return html`<hp-dialog .hass=${host.hass} .title=${t('summary.settings')}
icon="mdi:view-dashboard-outline" dismiss-on-scrim aria-busy=${String(dialog.busy)}
@hp-close=${close}>
<div class="body summary-editor">
${dialog.localOnly ? html`<p class="rhint">${t(
dialog.localOnlyHint,
)}</p>` : nothing}
${dialog.localOnly ? html`${showRow}${sizeRows}` : html`
<label>${t('summary.panel_title')}</label>
<input type="text" .value=${dialog.draft.title}
data-summary-error=${String(dialog.attempted && firstError?.path === 'title')}
aria-invalid=${fieldProblem('title')?.kind === 'error' ? 'true' : 'false'}
@input=${(event: Event) => context.mutate((draft) => {
draft.title = (event.target as HTMLInputElement).value;
})} />
${fieldProblem('title')
? html`<div class="summary-problem error">${problemText(fieldProblem('title'))}</div>` : nothing}
${showRow}
<label class="summary-switch"><input type="checkbox" .checked=${dialog.draft.show_on_mobile}
@change=${(event: Event) => context.mutate((draft) => {
draft.show_on_mobile = (event.target as HTMLInputElement).checked;
})} />
<span>${t('summary.show_mobile')}</span></label>
${sizeRows}
<label>${t('summary.search_entities')}</label>
<input type="search" .value=${dialog.entityFilter}
@input=${(event: Event) => context.setDialog({
...dialog, entityFilter: (event.target as HTMLInputElement).value,
})} />
${entities.length ? nothing : html`<div class="summary-empty">${t('summary.no_search_results')}</div>`}
<div class="summary-editor-blocks">
${dialog.draft.blocks.map((block, bi) => {
const bp = `blocks.${bi}`;
const scopeProblem = fieldProblem(`${bp}.scope`);
return html`<article class="summary-editor-block"
@dragover=${(event: DragEvent) => event.preventDefault()}
@drop=${(event: DragEvent) => context.drop(event, `block:${bi}`)}>
<div class="summary-editor-row summary-block-head">
<span class="summary-drag" draggable="true" title=${t('summary.drag')}
@dragstart=${(event: DragEvent) => context.dragStart(event, `block:${bi}`)}>⋮⋮</span>
<input type="text" .value=${block.title}
placeholder=${t('summary.block_title')}
data-summary-error=${String(dialog.attempted && firstError?.path === `${bp}.title`)}
@input=${(event: Event) => context.mutate((draft) => {
draft.blocks[bi].title = (event.target as HTMLInputElement).value;
})} />
<button type="button" title=${t('summary.up')} ?disabled=${bi === 0}
@click=${() => context.mutate((draft) => {
draft.blocks = moveSummaryItem(draft.blocks, bi, bi - 1);
})}>↑</button>
<button type="button" title=${t('summary.down')}
?disabled=${bi === dialog.draft.blocks.length - 1}
@click=${() => context.mutate((draft) => {
draft.blocks = moveSummaryItem(draft.blocks, bi, bi + 1);
})}>↓</button>
<button type="button" title=${t('btn.delete')}
@click=${() => context.deleteBlock(bi)}><ha-icon icon="mdi:delete-outline"></ha-icon></button>
</div>
${fieldProblem(`${bp}.title`)
? html`<div class="summary-problem error">${problemText(fieldProblem(`${bp}.title`))}</div>` : nothing}
<div class="summary-editor-row">
<label class="summary-switch"><input type="checkbox" .checked=${block.visible}
@change=${(event: Event) => context.mutate((draft) => {
draft.blocks[bi].visible = (event.target as HTMLInputElement).checked;
})} />
<span>${t('summary.block_visible')}</span></label>
<select .value=${block.scope.type}
@change=${(event: Event) => context.mutate((draft) => {
draft.blocks[bi].scope = (event.target as HTMLSelectElement).value === 'space'
? { type: 'space', space_id: host._space } : { type: 'all' };
})}>
<option value="all" ?selected=${block.scope.type === 'all'}>${t('summary.scope_all')}</option>
<option value="space" ?selected=${block.scope.type === 'space'}>${t('summary.scope_space')}</option>
</select>
${block.scope.type === 'space' ? html`<select .value=${block.scope.space_id}
data-summary-error=${String(dialog.attempted && firstError?.path === `${bp}.scope`)}
@change=${(event: Event) => context.mutate((draft) => {
const scope = draft.blocks[bi].scope;
if (scope.type === 'space') scope.space_id = (event.target as HTMLSelectElement).value;
})}>
${!host._model.some((space) => space.id === block.scope.space_id)
? html`<option value=${block.scope.space_id} selected>${block.scope.space_id}</option>` : nothing}
${host._model.map((space) => html`<option value=${space.id}
?selected=${space.id === block.scope.space_id}>${space.title}</option>`)}
</select>` : nothing}
</div>
${scopeProblem
? html`<div class="summary-problem ${scopeProblem.kind}">${problemText(scopeProblem)}</div>` : nothing}
<div class="summary-editor-values">
${block.values.map((value, vi) => {
const vp = `${bp}.values.${vi}`;
const sourceProblem = fieldProblem(`${vp}.source`);
const sourceToken = context.sourceToken(value.source);
return html`<div class="summary-editor-value"
@dragover=${(event: DragEvent) => { event.preventDefault(); event.stopPropagation(); }}
@drop=${(event: DragEvent) => context.drop(event, `value:${bi}:${vi}`)}>
<div class="summary-editor-row">
<span class="summary-drag" draggable="true" title=${t('summary.drag')}
@dragstart=${(event: DragEvent) => context.dragStart(event, `value:${bi}:${vi}`)}>⋮⋮</span>
<input type="text" .value=${value.label}
placeholder=${t('summary.value_name')}
data-summary-error=${String(dialog.attempted && firstError?.path === `${vp}.label`)}
@input=${(event: Event) => context.mutate((draft) => {
draft.blocks[bi].values[vi].label = (event.target as HTMLInputElement).value;
})} />
<button type="button" title=${t('summary.up')} ?disabled=${vi === 0}
@click=${() => context.mutate((draft) => {
draft.blocks[bi].values = moveSummaryItem(draft.blocks[bi].values, vi, vi - 1);
})}>↑</button>
<button type="button" title=${t('summary.down')} ?disabled=${vi === block.values.length - 1}
@click=${() => context.mutate((draft) => {
draft.blocks[bi].values = moveSummaryItem(draft.blocks[bi].values, vi, vi + 1);
})}>↓</button>
<button type="button" title=${t('btn.delete')}
@click=${() => context.mutate((draft) => {
draft.blocks[bi].values.splice(vi, 1);
})}><ha-icon icon="mdi:delete-outline"></ha-icon></button>
</div>
${fieldProblem(`${vp}.label`)
? html`<div class="summary-problem error">${problemText(fieldProblem(`${vp}.label`))}</div>` : nothing}
<select class="summary-source" .value=${sourceToken}
data-summary-error=${String(dialog.attempted && firstError?.path === `${vp}.source`)}
@change=${(event: Event) => context.setSource(
bi, vi, (event.target as HTMLSelectElement).value,
)}>
<optgroup label=${t('summary.system_group')}>
<option value="system:device_count" ?selected=${sourceToken === 'system:device_count'}>${t('summary.system.device_count')}</option>
<option value="system:total_area" ?selected=${sourceToken === 'system:total_area'}>${t('summary.system.total_area')}</option>
<option value="system:datetime" ?selected=${sourceToken === 'system:datetime'}>${t('summary.system.datetime')}</option>
</optgroup>
<optgroup label=${t('summary.entities_group')}>
${value.source.type === 'entity' && !value.source.entity_id
? html`<option value="entity:" selected>${t('summary.select_source')}</option>` : nothing}
${value.source.type === 'entity' && !entities.some(([id]) => id === value.source.entity_id)
? html`<option value=${`entity:${value.source.entity_id}`} selected>${
host.hass?.states?.[value.source.entity_id]?.attributes?.friendly_name || value.source.entity_id
} — ${value.source.entity_id}</option>` : nothing}
${entities.map(([id, state]) => html`<option value=${`entity:${id}`}
?selected=${sourceToken === `entity:${id}`}>
${state.attributes?.friendly_name || id} — ${id}
</option>`)}
</optgroup>
</select>
${sourceProblem
? html`<div class="summary-problem ${sourceProblem.kind}">${problemText(sourceProblem)}</div>` : nothing}
</div>`;
})}
<button class="btn ghost summary-add" type="button"
?disabled=${block.values.length >= 20}
title=${block.values.length >= 20 ? t('summary.limit_values') : ''}
@click=${() => context.mutate((draft) => {
draft.blocks[bi].values.push(newSummaryValue(t));
})}>
<ha-icon icon="mdi:plus"></ha-icon>${t('summary.add_value')}
</button>
</div>
</article>`;
})}
</div>
<button class="btn ghost summary-add" type="button"
?disabled=${dialog.draft.blocks.length >= 10}
title=${dialog.draft.blocks.length >= 10 ? t('summary.limit_blocks') : ''}
@click=${() => context.mutate((draft) => {
draft.blocks.push(newSummaryBlock(t));
})}>
<ha-icon icon="mdi:plus"></ha-icon>${t('summary.add_block')}
</button>
${dialog.error ? html`<div class="summary-problem error" role="alert">${dialog.error}</div>` : nothing}
${dialog.conflict ? html`<button class="btn ghost summary-reload" type="button"
?disabled=${dialog.busy} @click=${() => context.reload()}>
<ha-icon icon="mdi:reload"></ha-icon>${t('summary.reload_current')}
</button>` : nothing}
`}
</div>
<div class="row" slot="footer">
<button class="btn ghost" ?disabled=${dialog.busy} @click=${close}>${t('btn.cancel')}</button>
<span class="spacer"></span>
<button class="btn on" ?disabled=${dialog.localOnly ? !changed || dialog.busy : !canSave}
@click=${() => context.save()}><ha-icon icon="mdi:check"></ha-icon>${t('btn.save')}</button>
</div>
</hp-dialog>`;
};
+76
View File
@@ -0,0 +1,76 @@
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { I18nKey } from './i18n';
import type { CardConfig, Marker, ServerConfig, SpaceModel } from './types';
export interface SummaryHassState {
state?: string;
attributes?: {
friendly_name?: string;
unit_of_measurement?: string;
[key: string]: unknown;
};
}
export interface SummaryHass {
states?: Record<string, SummaryHassState>;
user?: { id?: string; name?: string };
config?: {
time_zone?: string;
unit_system?: { length?: string };
};
[key: string]: unknown;
}
/**
* Narrow structural boundary between the card and the lazy #437 runtime.
* The card predates a public host interface; keeping the cast at construction
* prevents the lazy modules from spreading that legacy untyped boundary.
*/
export interface SummaryPanelHost extends Node {
hass?: SummaryHass;
panelHost: boolean;
narrow: boolean | null;
renderRoot: ShadowRoot;
ownerDocument: Document;
isConnected: boolean;
updateComplete: Promise<unknown>;
requestUpdate(): void;
_mode: 'view' | 'plan' | 'devices' | 'decor';
_space: string;
_settings: ServerConfig['settings'];
_config?: CardConfig;
_model: SpaceModel[];
_markers: Marker[];
_serverCfg: ServerConfig | null;
_cfgRev: number;
_cfgEpoch: number;
_layoutRev: number;
_cfgContentFingerprint: string;
_writesPending: number;
_writeChain: Promise<void>;
_kiosk: boolean;
_kioskScale: { icon: number; font: number };
_haSummaryPanelApi: number | null;
_canManageConfiguration: boolean;
_haRegistry: HaRegistrySnapshot;
_areaToSpace: Record<string, { space: string }>;
_stageEl: HTMLElement | null;
_t(key: I18nKey): string;
_errText(error: unknown): string;
_showToast?(message: string): void;
_confirmDanger(request: {
key: string;
kind: 'warning';
title: string;
message: string;
objectName: string;
confirmLabel: string;
cancelLabel: string;
}): Promise<boolean>;
_sendConfigCandidate(candidate: ServerConfig): Promise<void>;
_getAuthoritativeConfig(): Promise<{ config?: ServerConfig | null; rev?: number }>;
_reloadConfigOnly(force?: boolean): Promise<void>;
_cacheSnapshot(): void;
}
+240
View File
@@ -0,0 +1,240 @@
/** Lazy RU/EN/DE/FR copy owned by the #437 summary runtime. */
const dictionaries = {
"en": {
"summary.default_title": "Summary",
"summary.default_block": "General",
"summary.system.device_count": "Device count",
"summary.system.total_area": "Total room area",
"summary.system.datetime": "Date and time",
"summary.new_block": "New block",
"summary.value_name": "Value name",
"summary.delete_block_title": "Delete block?",
"summary.delete_block_body": "This block contains values. It will be removed from the draft.",
"summary.save_failed": "Could not save the summary panel.",
"summary.conflict": "The configuration changed in another window. Load the current version to start again.",
"summary.reload_current": "Load current configuration",
"summary.unavailable": "Source unavailable",
"summary.empty_block": "No values in this block",
"summary.empty_space": "No values for this space",
"summary.measure_block": "Block",
"summary.measure_label": "Status",
"summary.controls": "Summary panel controls",
"summary.settings": "Summary panel settings",
"summary.show": "Show summary panel",
"summary.hide": "Hide summary panel",
"summary.hidden_mobile": "The panel is enabled locally but disabled on mobile",
"summary.hidden_narrow_unknown": "The panel is enabled locally and is waiting for Home Assistant to report the screen mode",
"summary.hidden_small": "The panel is enabled locally but this card is too small",
"summary.show_local": "Show panel on this card",
"summary.backend_required": "Update the House Plan integration to edit shared panel contents. Local display settings are still available.",
"summary.local_only": "You can change settings for this card. Shared panel contents are managed by an administrator.",
"summary.sizes_title": "Sizes on this screen",
"summary.no_search_results": "No matching entities",
"summary.storage_unavailable": "These screen settings work for this session but cannot be saved in this browser.",
"summary.load_failed": "Could not open the panel settings.",
"summary.unsupported_schema": "This panel was created by a newer House Plan version and cannot be shown here.",
"summary.panel_title": "Panel title",
"summary.show_mobile": "Display on mobile devices",
"summary.block_title": "Block title",
"summary.up": "Move up",
"summary.down": "Move down",
"summary.drag": "Drag to reorder",
"summary.select_source": "Select a source",
"summary.block_visible": "Visible",
"summary.scope_all": "All spaces",
"summary.scope_space": "Specific space",
"summary.system_group": "System values",
"summary.entities_group": "Home Assistant entities",
"summary.search_entities": "Search entities by name or entity ID",
"summary.limit_values": "A block can contain up to 20 values",
"summary.add_value": "Add value",
"summary.limit_blocks": "The panel can contain up to 10 blocks",
"summary.add_block": "Add block",
"summary.problem.required": "Fill in this field.",
"summary.problem.limit": "The allowed limit has been exceeded.",
"summary.problem.duplicate_id": "This internal identifier is duplicated.",
"summary.problem.invalid_source": "Select a valid source.",
"summary.problem.missing_entity": "This entity is no longer available. Select another source to replace it.",
"summary.problem.missing_space": "This space is no longer available. Select another scope to replace it."
},
"ru": {
"summary.default_title": "Сводная информация",
"summary.default_block": "Общее",
"summary.system.device_count": "Количество устройств",
"summary.system.total_area": "Общая площадь комнат",
"summary.system.datetime": "Дата и время",
"summary.new_block": "Новый блок",
"summary.value_name": "Название показателя",
"summary.delete_block_title": "Удалить блок?",
"summary.delete_block_body": "В блоке есть показатели. Он будет удалён из черновика.",
"summary.save_failed": "Не удалось сохранить сводную панель.",
"summary.conflict": "Конфигурация изменилась в другом окне. Загрузите актуальную версию, чтобы начать заново.",
"summary.reload_current": "Загрузить актуальную конфигурацию",
"summary.unavailable": "Источник недоступен",
"summary.empty_block": "В этом блоке нет показателей",
"summary.empty_space": "Нет показателей для этого пространства",
"summary.measure_block": "Блок",
"summary.measure_label": "Состояние",
"summary.controls": "Управление сводной панелью",
"summary.settings": "Настройки сводной панели",
"summary.show": "Показать сводную панель",
"summary.hide": "Скрыть сводную панель",
"summary.hidden_mobile": "Панель включена локально, но отключена на мобильных устройствах",
"summary.hidden_narrow_unknown": "Панель включена локально и ожидает от Home Assistant информацию о режиме экрана",
"summary.hidden_small": "Панель включена локально, но карточка слишком мала",
"summary.show_local": "Показывать панель в этой карточке",
"summary.backend_required": "Обновите интеграцию House Plan, чтобы менять общее содержимое панели. Локальные настройки доступны.",
"summary.local_only": "Вы можете менять настройки этой карточки. Общим содержимым панели управляет администратор.",
"summary.sizes_title": "Размеры на этом экране",
"summary.no_search_results": "Подходящие сущности не найдены",
"summary.storage_unavailable": "Настройки экрана действуют в этой сессии, но браузер не может их сохранить.",
"summary.load_failed": "Не удалось открыть настройки панели.",
"summary.unsupported_schema": "Эта панель создана в более новой версии House Plan и не может быть здесь показана.",
"summary.panel_title": "Название панели",
"summary.show_mobile": "Отображать на мобильных устройствах",
"summary.block_title": "Название блока",
"summary.up": "Выше",
"summary.down": "Ниже",
"summary.drag": "Перетащить для изменения порядка",
"summary.select_source": "Выберите источник",
"summary.block_visible": "Показывать",
"summary.scope_all": "Все пространства",
"summary.scope_space": "Определённое пространство",
"summary.system_group": "Системные показатели",
"summary.entities_group": "Сущности Home Assistant",
"summary.search_entities": "Поиск сущностей по названию или entity ID",
"summary.limit_values": "В блоке может быть не больше 20 показателей",
"summary.add_value": "Добавить значение",
"summary.limit_blocks": "В панели может быть не больше 10 блоков",
"summary.add_block": "Добавить блок",
"summary.problem.required": "Заполните это поле.",
"summary.problem.limit": "Превышено допустимое ограничение.",
"summary.problem.duplicate_id": "Внутренний идентификатор повторяется.",
"summary.problem.invalid_source": "Выберите корректный источник.",
"summary.problem.missing_entity": "Эта сущность больше недоступна. Выберите другой источник, чтобы заменить её.",
"summary.problem.missing_space": "Это пространство больше недоступно. Выберите другую область, чтобы заменить его."
},
"de": {
"summary.default_title": "Übersicht",
"summary.default_block": "Allgemein",
"summary.system.device_count": "Anzahl der Geräte",
"summary.system.total_area": "Gesamte Raumfläche",
"summary.system.datetime": "Datum und Uhrzeit",
"summary.new_block": "Neuer Block",
"summary.value_name": "Bezeichnung",
"summary.delete_block_title": "Block löschen?",
"summary.delete_block_body": "Dieser Block enthält Werte und wird aus dem Entwurf entfernt.",
"summary.save_failed": "Die Übersicht konnte nicht gespeichert werden.",
"summary.conflict": "Die Konfiguration wurde in einem anderen Fenster geändert. Laden Sie die aktuelle Version, um neu zu beginnen.",
"summary.reload_current": "Aktuelle Konfiguration laden",
"summary.unavailable": "Quelle nicht verfügbar",
"summary.empty_block": "Keine Werte in diesem Block",
"summary.empty_space": "Keine Werte für diesen Bereich",
"summary.measure_block": "Block",
"summary.measure_label": "Status",
"summary.controls": "Steuerung der Übersicht",
"summary.settings": "Einstellungen der Übersicht",
"summary.show": "Übersicht anzeigen",
"summary.hide": "Übersicht ausblenden",
"summary.hidden_mobile": "Die Übersicht ist lokal aktiv, aber auf Mobilgeräten deaktiviert",
"summary.hidden_narrow_unknown": "Die Übersicht ist lokal aktiv und wartet auf den Bildschirmmodus von Home Assistant",
"summary.hidden_small": "Die Übersicht ist lokal aktiv, aber diese Karte ist zu klein",
"summary.show_local": "Übersicht in dieser Karte anzeigen",
"summary.backend_required": "Aktualisieren Sie die House-Plan-Integration, um gemeinsame Inhalte zu bearbeiten. Lokale Einstellungen bleiben verfügbar.",
"summary.local_only": "Sie können Einstellungen dieser Karte ändern. Gemeinsame Inhalte verwaltet ein Administrator.",
"summary.sizes_title": "Größen auf diesem Bildschirm",
"summary.no_search_results": "Keine passenden Entitäten",
"summary.storage_unavailable": "Diese Bildschirmeinstellungen gelten für diese Sitzung, können aber in diesem Browser nicht gespeichert werden.",
"summary.load_failed": "Die Panel-Einstellungen konnten nicht geöffnet werden.",
"summary.unsupported_schema": "Dieses Panel wurde mit einer neueren House-Plan-Version erstellt und kann hier nicht angezeigt werden.",
"summary.panel_title": "Titel der Übersicht",
"summary.show_mobile": "Auf Mobilgeräten anzeigen",
"summary.block_title": "Blocktitel",
"summary.up": "Nach oben",
"summary.down": "Nach unten",
"summary.drag": "Zum Sortieren ziehen",
"summary.select_source": "Quelle auswählen",
"summary.block_visible": "Sichtbar",
"summary.scope_all": "Alle Bereiche",
"summary.scope_space": "Bestimmter Bereich",
"summary.system_group": "Systemwerte",
"summary.entities_group": "Home-Assistant-Entitäten",
"summary.search_entities": "Entitäten nach Name oder Entity-ID suchen",
"summary.limit_values": "Ein Block kann bis zu 20 Werte enthalten",
"summary.add_value": "Wert hinzufügen",
"summary.limit_blocks": "Die Übersicht kann bis zu 10 Blöcke enthalten",
"summary.add_block": "Block hinzufügen",
"summary.problem.required": "Füllen Sie dieses Feld aus.",
"summary.problem.limit": "Das zulässige Limit wurde überschritten.",
"summary.problem.duplicate_id": "Diese interne Kennung wird mehrfach verwendet.",
"summary.problem.invalid_source": "Wählen Sie eine gültige Quelle.",
"summary.problem.missing_entity": "Diese Entität ist nicht mehr verfügbar. Wählen Sie zum Ersetzen eine andere Quelle.",
"summary.problem.missing_space": "Dieser Bereich ist nicht mehr verfügbar. Wählen Sie zum Ersetzen einen anderen Geltungsbereich."
},
"fr": {
"summary.default_title": "Résumé",
"summary.default_block": "Général",
"summary.system.device_count": "Nombre d’appareils",
"summary.system.total_area": "Surface totale des pièces",
"summary.system.datetime": "Date et heure",
"summary.new_block": "Nouveau bloc",
"summary.value_name": "Nom de la valeur",
"summary.delete_block_title": "Supprimer le bloc ?",
"summary.delete_block_body": "Ce bloc contient des valeurs et sera supprimé du brouillon.",
"summary.save_failed": "Impossible d’enregistrer le panneau de résumé.",
"summary.conflict": "La configuration a changé dans une autre fenêtre. Chargez la version actuelle pour recommencer.",
"summary.reload_current": "Charger la configuration actuelle",
"summary.unavailable": "Source indisponible",
"summary.empty_block": "Aucune valeur dans ce bloc",
"summary.empty_space": "Aucune valeur pour cet espace",
"summary.measure_block": "Bloc",
"summary.measure_label": "État",
"summary.controls": "Commandes du panneau de résumé",
"summary.settings": "Paramètres du panneau de résumé",
"summary.show": "Afficher le panneau de résumé",
"summary.hide": "Masquer le panneau de résumé",
"summary.hidden_mobile": "Le panneau est activé localement mais désactivé sur mobile",
"summary.hidden_narrow_unknown": "Le panneau est activé localement et attend le mode d’écran de Home Assistant",
"summary.hidden_small": "Le panneau est activé localement mais cette carte est trop petite",
"summary.show_local": "Afficher le panneau dans cette carte",
"summary.backend_required": "Mettez à jour l’intégration House Plan pour modifier le contenu partagé. Les réglages locaux restent disponibles.",
"summary.local_only": "Vous pouvez modifier les réglages de cette carte. Le contenu partagé est géré par un administrateur.",
"summary.sizes_title": "Tailles pour cet écran",
"summary.no_search_results": "Aucune entité correspondante",
"summary.storage_unavailable": "Ces réglages d’écran s’appliquent à cette session, mais ce navigateur ne peut pas les enregistrer.",
"summary.load_failed": "Impossible d’ouvrir les paramètres du panneau.",
"summary.unsupported_schema": "Ce panneau a été créé par une version plus récente de House Plan et ne peut pas être affiché ici.",
"summary.panel_title": "Titre du panneau",
"summary.show_mobile": "Afficher sur les appareils mobiles",
"summary.block_title": "Titre du bloc",
"summary.up": "Monter",
"summary.down": "Descendre",
"summary.drag": "Faire glisser pour réorganiser",
"summary.select_source": "Sélectionner une source",
"summary.block_visible": "Visible",
"summary.scope_all": "Tous les espaces",
"summary.scope_space": "Espace spécifique",
"summary.system_group": "Valeurs système",
"summary.entities_group": "Entités Home Assistant",
"summary.search_entities": "Rechercher par nom ou identifiant d’entité",
"summary.limit_values": "Un bloc peut contenir jusqu’à 20 valeurs",
"summary.add_value": "Ajouter une valeur",
"summary.limit_blocks": "Le panneau peut contenir jusqu’à 10 blocs",
"summary.add_block": "Ajouter un bloc",
"summary.problem.required": "Remplissez ce champ.",
"summary.problem.limit": "La limite autorisée est dépassée.",
"summary.problem.duplicate_id": "Cet identifiant interne est dupliqué.",
"summary.problem.invalid_source": "Sélectionnez une source valide.",
"summary.problem.missing_entity": "Cette entité n’est plus disponible. Sélectionnez une autre source pour la remplacer.",
"summary.problem.missing_space": "Cet espace n’est plus disponible. Sélectionnez une autre portée pour le remplacer."
}
} as const;
export function summaryPanelText(language: string, key: string): string {
const code = language.toLowerCase().split('-')[0] as keyof typeof dictionaries;
const selected = dictionaries[code] || dictionaries.en;
return (selected as Record<string, string>)[key]
|| (dictionaries.en as Record<string, string>)[key] || key;
}
export const summaryPanelDictionaries: Readonly<Record<string, Readonly<Record<string, string>>>> = dictionaries;
+117
View File
@@ -0,0 +1,117 @@
import { union, type Geom } from 'polyclip-ts';
import { formatArea } from './area-format';
import { removedPlanBindings } from './devices';
import { geometryArea, floorMinusBodies } from './physical-geometry';
import { prepareSpacePhysicalGeometryInputs } from './plan-geometry-preflight';
import { GRID_PITCH, GRID_STEP_N, NORM_W } from './space-geometry';
import { hassValue, roomPoly, valueWithUnit } from './logic';
import { innerContourForRoom } from './wall-thickness';
import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { SummaryHass } from './summary-panel-host';
/** Count the unique real HA devices represented anywhere on the plan. */
export function representedHaDeviceIds(input: {
registry: HaRegistrySnapshot;
areaToSpace: Record<string, string>;
spaceIds: ReadonlySet<string>;
firstSpaceId: string;
markers: readonly Marker[];
}): Set<string> | null {
if (!input.registry.authoritative) return null;
const out = new Set<string>();
const removed = removedPlanBindings(input.markers);
for (const device of Object.values(input.registry.devices || {})) {
if (!device?.id || device.entry_type === 'service' || removed.devices.has(device.id)) continue;
if (device.area_id && input.areaToSpace[device.area_id]) out.add(device.id);
}
for (const marker of input.markers || []) {
if (marker.removed || marker.binding === 'virtual') continue;
const separator = String(marker.binding || '').indexOf(':');
if (separator < 1) continue;
const kind = marker.binding.slice(0, separator);
const ref = marker.binding.slice(separator + 1);
const device = kind === 'device' ? input.registry.devices?.[ref] : null;
const entity = kind === 'entity' ? input.registry.entities?.[ref] : null;
const deviceId = kind === 'device' ? ref : entity?.device_id;
const registryArea = kind === 'device' ? device?.area_id
: entity?.area_id || (deviceId && input.registry.devices?.[deviceId]?.area_id);
const manualRoomWithoutArea = typeof marker.room_id === 'string'
&& marker.room_id.length > 0 && marker.area === null;
const area = manualRoomWithoutArea ? '' : marker.area || registryArea || '';
const space = area && input.areaToSpace[area] || marker.space || input.firstSpaceId;
if (deviceId && input.registry.devices?.[deviceId]
&& input.spaceIds.has(space)
&& (!removed.devices.has(deviceId) || kind === 'entity' && removed.liveEntities.has(ref))) {
out.add(deviceId);
}
}
return out;
}
function unionGeometry(current: Geom | null, next: Geom): Geom {
if (!current?.length) return next;
if (!next?.length) return current;
return union(current, next);
}
/** Canonical clean-floor union, in physical square metres, for every space. */
export function totalCleanFloorAreaM2(
config: ServerConfig,
models: readonly SpaceModel[],
): number | null {
try {
let total = 0;
for (const space of models) {
const raw = config.spaces.find((item) => String(item?.id) === space.id);
if (!raw) continue;
const prepared = prepareSpacePhysicalGeometryInputs(raw, space);
let spaceFloor: Geom | null = null;
for (const room of space.rooms) {
if (!room.id) continue;
const poly = roomPoly(room);
if (!poly) continue;
const inner = innerContourForRoom(
space.rooms, room.id, prepared.walls, prepared.openCuts,
GRID_STEP_N, prepared.cellCm, GRID_PITCH, NORM_W,
) || poly;
const clean = floorMinusBodies(inner, prepared.physicalBodies) as Geom;
spaceFloor = unionGeometry(spaceFloor, clean);
}
const cmPerUnit = prepared.cellCm / GRID_PITCH;
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
}
return total;
} catch {
return null;
}
}
export function summarySystemValue(
source: SummaryPanelSource,
values: { deviceCount: number | null; areaM2: number | null; now: Date },
hass: SummaryHass | undefined,
locale: string,
): string | null {
if (source.type !== 'system') return null;
if (source.key === 'device_count') return values.deviceCount === null ? null : String(values.deviceCount);
if (source.key === 'total_area') {
if (values.areaM2 === null) return null;
return formatArea(values.areaM2, hass?.config?.unit_system?.length === 'mi');
}
try {
return new Intl.DateTimeFormat(locale || undefined, {
dateStyle: 'short', timeStyle: 'short', timeZone: hass?.config?.time_zone || undefined,
}).format(values.now);
} catch {
return values.now.toLocaleString();
}
}
export function summaryEntityValue(hass: SummaryHass | undefined, entityId: string): string | null {
const state = hass?.states?.[entityId];
if (!state) return null;
const value = hassValue(hass, entityId);
if (!value) return null;
return valueWithUnit(value, String(state.attributes?.unit_of_measurement || ''));
}
+610
View File
@@ -0,0 +1,610 @@
import { html, nothing, type TemplateResult } from 'lit';
import { langOf, type I18nKey } from './i18n';
import type {
ServerConfig, SummaryPanelConfig, SummaryPanelSource, SummaryPanelSystemKey,
SummaryPanelValue,
} from './types';
import {
SUMMARY_PANEL_LEGACY_SCALE_KEY,
type SummaryDraftProblem, type SummaryPanelLocalPreferences,
cloneSummaryPanel, defaultSummaryPanel, effectiveSummaryVisible,
moveSummaryItem, normalizeSummaryDraft, parseSummaryLocal,
normalizeSummaryScale, resolveSummaryLayout, sameSummaryPanel,
summaryLocalKey, summaryPanelOf, validateSummaryDraft, visibleSummaryBlocks,
} from './summary-panel';
import { SUMMARY_PANEL_API_VERSION } from './summary-panel-api';
import { contentFingerprint } from './visual-continuity';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { canonicalizeConfigGeometry } from './coordinate-canonicalization';
import type { SummaryPanelEditorRenderer } from './summary-panel-editor';
import { summaryPanelCss } from './summary-panel-style';
import { summaryPanelText } from './summary-panel-i18n';
import type { SummaryPanelHost } from './summary-panel-host';
const isSummarySystemKey = (value: string): value is SummaryPanelSystemKey =>
value === 'device_count' || value === 'total_area' || value === 'datetime';
export type SummaryPanelDialogState = {
draft: SummaryPanelConfig;
base: SummaryPanelConfig;
baseRevision: number;
localShow: boolean;
baseLocalShow: boolean;
localOnly: boolean;
localOnlyHint: 'summary.local_only' | 'summary.backend_required' | 'summary.unsupported_schema';
busy: boolean;
attempted: boolean;
entityFilter: string;
error: string;
conflict: boolean;
};
type SummaryLayoutState = {
width: number;
height: number;
minimumHeight: number;
controlTop: number;
};
/**
* Eager, small View runtime for #437. The much larger settings form is loaded
* only after an explicit press on the gear, keeping the ordinary plan path
* out of the editor chunk.
*
* The host is deliberately structural. HousePlanCard owns integration state;
* this controller owns only summary-panel local/UI state and asks the host to
* repaint after mutations.
*/
export class LoadedSummaryPanelRuntime {
private readonly host: SummaryPanelHost;
private dialog: SummaryPanelDialogState | null = null;
private local: SummaryPanelLocalPreferences = {
version: 1, show: false, icon_scale: 1, font_scale: 1,
};
private storageKey: string | null = null;
private stage: SummaryLayoutState = {
width: 0, height: 0, minimumHeight: 162, controlTop: 0,
};
private clock = new Date();
private clockTimer = 0;
private deviceMemo: {
cfgEpoch: number; layoutRev: number; registryRev: number; value: number | null;
} | null = null;
private areaMemo: { cfgEpoch: number; value: number | null } | null = null;
private storageUnavailable = false;
private clockContext = '';
private editorRenderer: SummaryPanelEditorRenderer | null = null;
private editorLoad: Promise<SummaryPanelEditorRenderer> | null = null;
private metricsModule: typeof import('./summary-panel-metrics') | null = null;
private metricsLoad: Promise<typeof import('./summary-panel-metrics')> | null = null;
public constructor(host: unknown) { this.host = host as SummaryPanelHost; }
public get dialogOpen(): boolean { return !!this.dialog; }
public connect(): void { this.ensureStyle(); this.loadLocal(); }
public disconnect(): void {
if (this.clockTimer) clearTimeout(this.clockTimer);
this.clockTimer = 0;
}
public visibility(kind: 'hidden' | 'visible' | 'pageshow'): void {
if (kind === 'hidden') {
if (this.clockTimer) clearTimeout(this.clockTimer);
this.clockTimer = 0;
return;
}
this.clock = new Date();
this.syncClock();
this.host.requestUpdate();
}
public updated(): void {
this.syncNativeNarrow();
this.loadLocal();
this.measureLayout();
this.syncClock();
}
public resized(): void { this.measureLayout(); }
public closeDialogIfIdle(): boolean {
if (!this.dialog || this.dialog.busy) return false;
this.dialog = null;
this.host.requestUpdate();
return true;
}
public blocksOtherDialogs(): boolean { return !!this.dialog; }
public saveScale(patch: Partial<{ icon: number; font: number }>): void {
this.saveLocal({
icon_scale: patch.icon ?? this.local.icon_scale,
font_scale: patch.font ?? this.local.font_scale,
});
}
public renderMeasure(): TemplateResult | typeof nothing {
const title = this.config().config?.title;
if (!title || this.host._mode !== 'view') return nothing;
return html`<div class="summary-measure" aria-hidden="true" inert>
<h2>${title}</h2><section><h3>${this.t('summary.measure_block')}</h3>
<div class="summary-value"><span>${this.t('summary.measure_label')}</span>
<strong>${this.t('summary.unavailable')}</strong></div></section>
</div><div class="summary-safe-probe" aria-hidden="true" inert></div>`;
}
public renderPanel(): TemplateResult | typeof nothing {
const resolved = this.config();
const config = resolved.config;
if (!config) return nothing;
const layout = this.layout();
const visible = effectiveSummaryVisible({
view: this.host._mode === 'view', localShow: this.local.show,
showOnMobile: config.show_on_mobile, narrow: this.host.narrow, fits: layout.fits,
});
if (!visible) return nothing;
this.ensureMetrics();
const blocks = visibleSummaryBlocks(config, this.host._space);
const stop = (event: Event) => event.stopPropagation();
return html`<aside class="summary-overlay ${layout.side}" aria-label=${config.title}
style="--summary-height-cap:${Math.floor(layout.heightCap)}px;--summary-width-cap:${Math.floor(layout.availableWidth)}px;--summary-top:${layout.top}px;--summary-bottom:${layout.bottom}px"
@click=${stop} @dblclick=${stop} @pointerdown=${stop} @pointerup=${stop}
@pointermove=${stop} @wheel=${stop}>
<h2>${config.title}</h2>
<div class="summary-scroll">
${blocks.length ? blocks.map((block) => html`<section class="summary-block">
<h3>${block.title}</h3>
${block.values.length ? block.values.map((value) => html`<div class="summary-value">
<span>${value.label}</span><strong>${this.value(value)}</strong>
</div>`) : html`<div class="summary-empty">${this.t('summary.empty_block')}</div>`}
</section>`) : html`<div class="summary-empty">${this.t('summary.empty_space')}</div>`}
</div>
</aside>`;
}
public renderControls(kiosk = false): TemplateResult | typeof nothing {
if (this.host._mode !== 'view') return nothing;
const resolved = this.config();
const layout = this.layout();
const temporaryReason = this.local.show && (
resolved.unsupported ? this.t('summary.unsupported_schema')
: !resolved.config?.show_on_mobile && this.host.narrow === true
? this.t('summary.hidden_mobile')
: !resolved.config?.show_on_mobile && this.host.narrow === null
? this.t('summary.hidden_narrow_unknown')
: !layout.fits ? this.t('summary.hidden_small') : ''
);
const toggleTitle = temporaryReason || this.t(this.local.show ? 'summary.hide' : 'summary.show');
const stop = (event: Event) => event.stopPropagation();
return html`<div class="summary-control ${kiosk ? 'kiosk' : ''}" role="group"
aria-label=${this.t('summary.controls')} @click=${stop} @dblclick=${stop}
@pointerdown=${stop} @pointerup=${stop} @pointermove=${stop} @wheel=${stop}>
<button type="button" @click=${() => void this.openDialog()}
title=${this.t('summary.settings')} aria-label=${this.t('summary.settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
</button>
<button type="button" class=${this.local.show ? 'on' : ''}
aria-pressed=${this.local.show ? 'true' : 'false'}
title=${toggleTitle} aria-label=${toggleTitle}
@click=${() => this.saveLocal({ show: !this.local.show })}>
<ha-icon icon="mdi:view-dashboard-outline"></ha-icon>
</button>
</div>`;
}
public renderDialog(): TemplateResult | typeof nothing {
if (!this.dialog || !this.editorRenderer) return nothing;
return this.editorRenderer({
host: this.host,
dialog: this.dialog,
local: this.local,
storageUnavailable: this.storageUnavailable,
problems: this.problems(this.dialog),
setDialog: (dialog) => { this.dialog = dialog; this.host.requestUpdate(); },
saveLocal: (patch) => this.saveLocal(patch),
mutate: (mutate) => this.mutate(mutate),
deleteBlock: (index) => void this.deleteBlock(index),
dragStart: (event, token) => this.dragStart(event, token),
drop: (event, target) => this.drop(event, target),
sourceToken: (source) => this.sourceToken(source),
setSource: (blockIndex, valueIndex, token) => this.setSource(blockIndex, valueIndex, token),
save: () => void this.saveDialog(),
reload: () => void this.reloadDialog(),
close: () => this.closeDialogIfIdle(),
t: (key) => this.t(key),
});
}
private t(key: string): string {
return key.startsWith('summary.')
? summaryPanelText(langOf(this.host.hass, this.host._config?.language), key)
: this.host._t(key as I18nKey);
}
private translate = (key: string): string => this.t(key);
private ensureStyle(): void {
const root = this.host.renderRoot as ShadowRoot;
if (root.querySelector('style[data-hp-summary]')) return;
const style = this.host.ownerDocument.createElement('style');
style.dataset.hpSummary = 'true';
style.textContent = summaryPanelCss;
root.insertBefore(style, root.firstChild);
}
private placementSlot(): string {
const parts: string[] = [];
let node: Node | null = this.host;
for (let depth = 0; node && depth < 10; depth++) {
const root = node.getRootNode() as Document | ShadowRoot;
const parent = node.parentNode;
if (parent && 'children' in parent) {
const children = Array.from((parent as Element).children || []);
parts.unshift(`${(node as Element).localName || 'node'}:${Math.max(0, children.indexOf(node as Element))}`);
node = parent;
} else if (root instanceof ShadowRoot && root.host) node = root.host;
else break;
}
return `${contentFingerprint(this.host._config || {})}:${parts.join('/') || 'root'}`;
}
private preferenceKey(): string | null {
return summaryLocalKey({
userId: this.host.hass?.user?.id || this.host.hass?.user?.name,
path: location.pathname,
host: this.host.panelHost ? 'panel' : 'lovelace',
slot: this.placementSlot(),
});
}
private syncNativeNarrow(): void {
if (this.host.panelHost) return;
let node: Node | null = this.host;
for (let depth = 0; node && depth < 12; depth++) {
const root = node.getRootNode() as Document | ShadowRoot;
node = node.parentNode || (root instanceof ShadowRoot ? root.host : null);
if (!node || node === this.host) continue;
const value = (node as Node & { narrow?: unknown }).narrow;
if (typeof value === 'boolean') {
if (this.host.narrow !== value) this.host.narrow = value;
return;
}
}
}
private loadLocal(): void {
const key = this.preferenceKey();
if (!key || key === this.storageKey) return;
let raw: unknown = null;
let legacy: unknown = null;
let storageAvailable = true;
try { raw = JSON.parse(localStorage.getItem(key) || 'null'); } catch { storageAvailable = false; }
try { legacy = JSON.parse(localStorage.getItem(SUMMARY_PANEL_LEGACY_SCALE_KEY) || 'null'); } catch { storageAvailable = false; }
this.storageKey = key;
this.storageUnavailable = !storageAvailable;
this.local = parseSummaryLocal(raw, legacy);
this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };
}
private saveLocal(patch: Partial<SummaryPanelLocalPreferences>): void {
this.local = {
...this.local, ...patch, version: 1,
icon_scale: normalizeSummaryScale(patch.icon_scale ?? this.local.icon_scale),
font_scale: normalizeSummaryScale(patch.font_scale ?? this.local.font_scale),
};
this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };
this.storageKey ||= this.preferenceKey();
let stored = false;
try {
if (this.storageKey) {
localStorage.setItem(this.storageKey, JSON.stringify(this.local));
stored = true;
}
} catch { /* local preferences remain effective for this session */ }
this.storageUnavailable = !stored;
if (!stored) this.host._showToast?.(this.t('summary.storage_unavailable'));
this.host.requestUpdate();
}
private config(): { config: SummaryPanelConfig | null; derived: boolean; unsupported: boolean } {
return summaryPanelOf(this.host._settings, this.translate);
}
private async openDialog(): Promise<void> {
this.loadLocal();
try {
this.editorLoad ||= import('./summary-panel-editor').then((module) => module.renderSummaryPanelEditor);
this.editorRenderer = await this.editorLoad;
} catch (error) {
this.editorLoad = null;
this.host._showToast?.(`${this.t('summary.load_failed')} ${this.host._errText(error)}`);
return;
}
const resolved = this.config();
const base = resolved.config || defaultSummaryPanel(this.translate);
const backendUnsupported = this.host._haSummaryPanelApi !== SUMMARY_PANEL_API_VERSION;
const localOnlyHint = resolved.unsupported ? 'summary.unsupported_schema'
: backendUnsupported && this.host._canManageConfiguration
? 'summary.backend_required' : 'summary.local_only';
this.dialog = {
draft: cloneSummaryPanel(base), base: cloneSummaryPanel(base),
baseRevision: this.host._cfgRev,
localShow: this.local.show, baseLocalShow: this.local.show,
localOnly: this.host._kiosk || !this.host._canManageConfiguration
|| backendUnsupported || resolved.unsupported,
localOnlyHint,
busy: false, attempted: false, entityFilter: '', error: '', conflict: false,
};
this.host.requestUpdate();
}
private problems(dialog = this.dialog): SummaryDraftProblem[] {
if (!dialog || dialog.localOnly) return [];
return validateSummaryDraft(
normalizeSummaryDraft(dialog.draft), dialog.base,
new Set(this.host._model.map((space) => space.id)),
new Set(Object.keys(this.host.hass?.states || {})),
);
}
private mutate(mutate: (draft: SummaryPanelConfig) => void): void {
if (!this.dialog || this.dialog.busy || this.dialog.localOnly) return;
const draft = cloneSummaryPanel(this.dialog.draft);
mutate(draft);
this.dialog = { ...this.dialog, draft, error: '', conflict: false };
this.host.requestUpdate();
}
private async deleteBlock(index: number): Promise<void> {
const block = this.dialog?.draft.blocks[index];
if (!block) return;
if (block.values.length) {
const accepted = await this.host._confirmDanger({
key: 'summary-block', kind: 'warning',
title: this.t('summary.delete_block_title'), message: this.t('summary.delete_block_body'),
objectName: block.title, confirmLabel: this.t('btn.delete'), cancelLabel: this.t('btn.cancel'),
});
if (!accepted) return;
}
this.mutate((draft) => { draft.blocks.splice(index, 1); });
}
private sourceToken(source: SummaryPanelSource): string {
return source.type === 'system' ? `system:${source.key}` : `entity:${source.entity_id}`;
}
private setSource(blockIndex: number, valueIndex: number, token: string): void {
this.mutate((draft) => {
const value = draft.blocks[blockIndex]?.values[valueIndex];
if (!value) return;
if (token.startsWith('system:')) {
const key = token.slice(7);
if (isSummarySystemKey(key)) value.source = { type: 'system', key };
} else value.source = { type: 'entity', entity_id: token.startsWith('entity:') ? token.slice(7) : token };
});
}
private dragStart(event: DragEvent, token: string): void {
event.dataTransfer?.setData('text/x-houseplan-summary', token);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
private drop(event: DragEvent, target: string): void {
event.preventDefault();
event.stopPropagation();
const source = event.dataTransfer?.getData('text/x-houseplan-summary') || '';
const from = source.split(':').map(Number);
const to = target.split(':').map(Number);
if (source.startsWith('block:') && target.startsWith('block:')) {
this.mutate((draft) => { draft.blocks = moveSummaryItem(draft.blocks, from[1], to[1]); });
} else if (source.startsWith('value:') && target.startsWith('value:') && from[1] === to[1]) {
this.mutate((draft) => {
draft.blocks[from[1]].values = moveSummaryItem(draft.blocks[from[1]].values, from[2], to[2]);
});
}
}
private async saveDialog(): Promise<void> {
const dialog = this.dialog;
if (!dialog || dialog.busy) return;
const draft = normalizeSummaryDraft(dialog.draft);
const problems = dialog.localOnly ? [] : validateSummaryDraft(
draft, dialog.base, new Set(this.host._model.map((space) => space.id)),
new Set(Object.keys(this.host.hass?.states || {})),
);
if (problems.some((problem) => problem.kind === 'error')) {
this.dialog = { ...dialog, draft, attempted: true };
this.host.requestUpdate();
await this.host.updateComplete;
(this.host.renderRoot.querySelector('[data-summary-error="true"]') as HTMLElement | null)?.focus?.();
return;
}
this.dialog = { ...dialog, draft, busy: true, attempted: false, error: '', conflict: false };
this.host.requestUpdate();
try {
if (!dialog.localOnly && !sameSummaryPanel(draft, dialog.base)) {
this.host._writesPending++;
const write = enqueueSerializedWrite(this.host._writeChain, async () => {
if (!this.host._serverCfg) throw new Error(this.t('summary.save_failed'));
if (this.host._cfgRev !== dialog.baseRevision) throw new Error(this.t('summary.conflict'));
const candidate = canonicalizeConfigGeometry({
...this.host._serverCfg,
settings: { ...(this.host._serverCfg.settings || {}), summary_panel: draft },
}) as ServerConfig;
try {
await this.host._sendConfigCandidate(candidate);
} catch (writeError) {
try {
const authoritative = await this.host._getAuthoritativeConfig();
const saved = authoritative.config?.settings?.summary_panel;
if (!saved || !sameSummaryPanel(saved, draft)) throw writeError;
this.host._cfgRev = authoritative.rev ?? this.host._cfgRev;
} catch { throw writeError; }
}
this.host._serverCfg = candidate;
this.host._cfgContentFingerprint = contentFingerprint(candidate);
this.host._cacheSnapshot();
});
this.host._writeChain = write;
await write.finally(() => { this.host._writesPending--; });
}
this.saveLocal({ show: dialog.localShow });
this.dialog = null;
} catch (error) {
const conflict = (error as { code?: unknown } | null)?.code === 'conflict'
|| this.host._cfgRev !== dialog.baseRevision;
this.dialog = {
...(this.dialog || dialog), busy: false, conflict,
error: conflict ? this.t('summary.conflict') : this.host._errText(error),
};
}
this.host.requestUpdate();
}
private async reloadDialog(): Promise<void> {
const dialog = this.dialog;
if (!dialog || dialog.busy) return;
this.dialog = { ...dialog, busy: true, error: '', conflict: false };
this.host.requestUpdate();
try {
await this.host._reloadConfigOnly(true);
if (dialog.conflict && this.host._cfgRev === dialog.baseRevision) {
throw new Error(this.t('summary.load_failed'));
}
const resolved = this.config();
const base = resolved.config || defaultSummaryPanel(this.translate);
this.dialog = {
...dialog, draft: cloneSummaryPanel(base), base: cloneSummaryPanel(base),
baseRevision: this.host._cfgRev, busy: false, attempted: false,
error: '', conflict: false,
};
} catch (error) {
this.dialog = { ...dialog, busy: false, error: this.host._errText(error), conflict: true };
}
this.host.requestUpdate();
}
private metrics(): { deviceCount: number | null; areaM2: number | null; now: Date } {
const module = this.metricsModule;
if (!module) return { deviceCount: null, areaM2: null, now: this.clock };
if (!this.deviceMemo || this.deviceMemo.cfgEpoch !== this.host._cfgEpoch
|| this.deviceMemo.layoutRev !== this.host._layoutRev
|| this.deviceMemo.registryRev !== this.host._haRegistry.revision) {
const represented = module.representedHaDeviceIds({
registry: this.host._haRegistry,
areaToSpace: Object.fromEntries(Object.entries(this.host._areaToSpace).map(
([area, value]) => [area, value.space],
)),
spaceIds: new Set(this.host._model.map((space) => space.id)),
firstSpaceId: this.host._model[0]?.id || '', markers: this.host._markers,
});
this.deviceMemo = {
cfgEpoch: this.host._cfgEpoch, layoutRev: this.host._layoutRev,
registryRev: this.host._haRegistry.revision, value: represented?.size ?? null,
};
}
if (!this.areaMemo || this.areaMemo.cfgEpoch !== this.host._cfgEpoch) {
this.areaMemo = {
cfgEpoch: this.host._cfgEpoch,
value: this.host._serverCfg ? module.totalCleanFloorAreaM2(this.host._serverCfg, this.host._model) : null,
};
}
return { deviceCount: this.deviceMemo.value, areaM2: this.areaMemo.value, now: this.clock };
}
private value(value: SummaryPanelValue): string {
const module = this.metricsModule;
const resolved = !module ? null : value.source.type === 'entity'
? module.summaryEntityValue(this.host.hass, value.source.entity_id)
: module.summarySystemValue(
value.source, this.metrics(), this.host.hass,
langOf(this.host.hass, this.host._config?.language),
);
return resolved ?? this.t('summary.unavailable');
}
private ensureMetrics(): void {
if (this.metricsModule || this.metricsLoad) return;
this.metricsLoad = import('./summary-panel-metrics');
void this.metricsLoad.then((module) => {
this.metricsModule = module;
this.host.requestUpdate();
}).catch(() => undefined).finally(() => { this.metricsLoad = null; });
}
private layout() {
return resolveSummaryLayout({
width: this.stage.width, height: this.stage.height,
...this.safeInsets(),
controlTop: this.host._kiosk ? this.stage.controlTop : 0,
minimumHeight: this.stage.minimumHeight,
});
}
private measureLayout(): void {
const stage = this.host._stageEl;
if (!stage) return;
const probe = this.host.renderRoot.querySelector('.summary-measure') as HTMLElement | null;
const controls = this.host.renderRoot.querySelector('.summary-control.kiosk') as HTMLElement | null;
const stageBox = stage.getBoundingClientRect();
const controlBox = controls?.getBoundingClientRect();
const next = {
width: Math.round(stage.clientWidth), height: Math.round(stage.clientHeight),
minimumHeight: Math.max(162, Math.ceil(probe?.getBoundingClientRect().height || 0)),
controlTop: controlBox ? Math.max(0, Math.ceil(controlBox.bottom - stageBox.top + 12)) : 0,
};
if (next.width !== this.stage.width || next.height !== this.stage.height
|| next.minimumHeight !== this.stage.minimumHeight || next.controlTop !== this.stage.controlTop) {
this.stage = next;
this.host.requestUpdate();
}
}
private safeInsets(): { safeLeft: number; safeRight: number; safeTop: number; safeBottom: number } {
const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null;
if (!probe) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 };
const style = this.host.ownerDocument.defaultView?.getComputedStyle(probe);
if (!style) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 };
const px = (value: string): number => Number.parseFloat(value) || 0;
return {
safeLeft: px(style.paddingLeft), safeRight: px(style.paddingRight),
safeTop: px(style.paddingTop), safeBottom: px(style.paddingBottom),
};
}
private hasVisibleClock(): boolean {
const config = this.config().config;
if (this.host.ownerDocument.visibilityState === 'hidden' || !config) return false;
if (!effectiveSummaryVisible({
view: this.host._mode === 'view', localShow: this.local.show,
showOnMobile: config.show_on_mobile, narrow: this.host.narrow, fits: this.layout().fits,
})) return false;
return visibleSummaryBlocks(config, this.host._space).some((block) => block.values.some(
(value) => value.source.type === 'system' && value.source.key === 'datetime',
));
}
private syncClock(): void {
const context = `${langOf(this.host.hass, this.host._config?.language)}\n${
this.host.hass?.config?.time_zone || ''}`;
if (context !== this.clockContext) {
this.clockContext = context;
this.clock = new Date();
}
const needed = this.hasVisibleClock();
if (needed && !this.clockTimer) {
this.clock = new Date();
const tick = () => {
this.clock = new Date();
this.clockTimer = 0;
this.host.requestUpdate();
this.syncClock();
};
this.clockTimer = window.setTimeout(tick, 60_000 - Date.now() % 60_000 + 25);
} else if (!needed && this.clockTimer) {
clearTimeout(this.clockTimer);
this.clockTimer = 0;
}
}
}
+222
View File
@@ -0,0 +1,222 @@
/** Lazy screen-space styles for the #437 summary surface and settings form. */
export const summaryPanelCss = String.raw`
.summary-control {
display: inline-flex;
flex: 0 0 auto;
border: 1px solid var(--divider-color);
border-radius: 10px;
overflow: hidden;
background: var(--card-background-color, #fff);
}
.summary-control > button {
width: 46px;
height: 46px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-right: 1px solid var(--divider-color);
color: var(--primary-text-color);
background: transparent;
cursor: pointer;
}
.summary-control > button:last-child { border-right: 0; }
.summary-control > button.on {
color: var(--text-primary-color, #fff);
background: var(--primary-color);
}
.summary-control > button:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -3px;
}
.summary-control ha-icon { --mdc-icon-size: 21px; }
.summary-control.kiosk {
position: absolute;
z-index: 31;
top: calc(12px + env(safe-area-inset-top));
right: calc(12px + env(safe-area-inset-right));
box-shadow: 0 4px 14px rgb(0 0 0 / 22%);
}
.summary-overlay,
.summary-measure {
box-sizing: border-box;
font-size: 14px;
line-height: 1.35;
color: var(--primary-text-color);
background: color-mix(in srgb, var(--card-background-color, #fff) 94%, transparent);
border: 1px solid var(--divider-color);
border-radius: 16px;
box-shadow: 0 8px 28px rgb(0 0 0 / 24%);
}
.summary-overlay {
position: absolute;
z-index: 24;
display: flex;
flex-direction: column;
min-width: 280px;
max-height: var(--summary-height-cap);
overflow: hidden;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
pointer-events: auto;
touch-action: pan-y;
}
.summary-overlay.right {
top: var(--summary-top);
right: calc(12px + env(safe-area-inset-right));
bottom: var(--summary-bottom);
width: clamp(280px, 31cqw, 420px);
max-width: var(--summary-width-cap);
animation: hp-summary-in-right 190ms ease-out;
}
.summary-overlay.bottom {
right: auto;
bottom: var(--summary-bottom);
left: 50%;
width: max-content;
min-width: min(360px, var(--summary-width-cap));
max-width: var(--summary-width-cap);
transform: translateX(-50%);
animation: hp-summary-in-bottom 190ms ease-out;
}
.summary-overlay h2,
.summary-measure h2 {
margin: 0;
padding: 14px 16px 12px;
font-size: 1.16em;
line-height: 1.25;
overflow-wrap: anywhere;
}
.summary-scroll {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 0 10px 12px;
}
.summary-block,
.summary-measure section {
margin: 0 0 9px;
padding: 10px;
border-radius: 11px;
background: var(--secondary-background-color);
}
.summary-block:last-child { margin-bottom: 0; }
.summary-block h3,
.summary-measure h3 {
margin: 0 0 7px;
font-size: 1em;
line-height: 1.25;
overflow-wrap: anywhere;
}
.summary-value {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(min-content, auto);
gap: 10px;
align-items: baseline;
padding: 4px 0;
}
.summary-value span,
.summary-value strong { min-width: 0; overflow-wrap: anywhere; }
.summary-value strong { text-align: right; font-weight: 600; }
.summary-empty { padding: 8px 0; color: var(--secondary-text-color); }
.summary-measure {
position: fixed;
z-index: -1;
top: -10000px;
left: -10000px;
width: 280px;
visibility: hidden;
pointer-events: none;
}
.summary-safe-probe {
position: fixed;
z-index: -1;
top: -10000px;
left: -10000px;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
visibility: hidden;
pointer-events: none;
}
.summary-measure section { margin: 0 10px 12px; }
@keyframes hp-summary-in-right {
from { opacity: 0; translate: 10px 0; }
to { opacity: 1; translate: 0 0; }
}
@keyframes hp-summary-in-bottom {
from { opacity: 0; translate: 0 10px; }
to { opacity: 1; translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.summary-overlay { animation: none; }
}
.summary-editor { min-width: min(640px, calc(100vw - 56px)); }
.summary-editor > label:not(.summary-switch) {
display: block;
margin: 12px 0 5px;
font-weight: 600;
}
.summary-editor input[type='text'],
.summary-editor input[type='search'],
.summary-editor select {
box-sizing: border-box;
min-width: 0;
min-height: 38px;
padding: 7px 9px;
color: var(--primary-text-color);
background: var(--card-background-color, #fff);
border: 1px solid var(--divider-color);
border-radius: 8px;
}
.summary-editor input[type='text'],
.summary-editor input[type='search'] { width: 100%; }
.summary-editor [aria-invalid='true'] { border-color: var(--error-color, #db4437); }
.summary-switch { display: inline-flex; align-items: center; gap: 8px; margin: 8px 0; }
.summary-switch input { width: 18px; height: 18px; }
.summary-local-sizes {
display: grid;
grid-template-columns: max-content minmax(140px, 1fr) 48px;
gap: 8px 14px;
align-items: center;
}
.summary-sizes-title { margin: 14px 0 8px; font-size: 1em; }
.summary-size-reset { grid-column: 1 / -1; justify-self: start; }
.summary-editor-blocks { display: grid; gap: 12px; margin-top: 10px; }
.summary-editor-block {
padding: 12px;
border: 1px solid var(--divider-color);
border-radius: 12px;
background: var(--secondary-background-color);
}
.summary-editor-row { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; }
.summary-block-head { flex-wrap: nowrap; }
.summary-block-head input { flex: 1 1 180px; }
.summary-editor-row button {
min-width: 34px;
height: 34px;
padding: 0 7px;
border: 0;
border-radius: 7px;
color: var(--primary-text-color);
background: var(--card-background-color, #fff);
cursor: pointer;
}
.summary-editor-row button:disabled { opacity: .4; cursor: default; }
.summary-drag { color: var(--secondary-text-color); cursor: grab; letter-spacing: -4px; }
.summary-editor-values { display: grid; gap: 8px; margin-top: 10px; }
.summary-editor-value { padding-top: 8px; border-top: 1px solid var(--divider-color); }
.summary-editor-value input { flex: 1 1 180px; }
.summary-source { width: 100%; margin-top: 7px; }
.summary-problem { margin: 5px 0; font-size: .9em; }
.summary-problem.error { color: var(--error-color, #db4437); }
.summary-problem.warning { color: var(--warning-color, #f59e0b); }
.summary-add { margin-top: 9px; }
.summary-reload { margin-top: 8px; }
@media (max-width: 600px) {
.summary-editor { min-width: 0; width: calc(100vw - 44px); }
.summary-block-head { flex-wrap: wrap; }
.summary-block-head input { order: -1; flex-basis: 100%; }
}`;
+308
View File
@@ -0,0 +1,308 @@
import type {
ServerConfig, SummaryPanelBlock, SummaryPanelConfig, SummaryPanelValue,
} from './types';
export const SUMMARY_PANEL_LIMITS = Object.freeze({ blocks: 10, values: 20, title: 48, label: 64 });
export const SUMMARY_PANEL_LOCAL_PREFIX = 'houseplan.summary-panel.v1';
export const SUMMARY_PANEL_LEGACY_SCALE_KEY = 'houseplan_card_kiosk_v1';
export const SUMMARY_PANEL_MIN_WIDTH = 280;
export type SummaryTranslate = (key: string) => string;
export interface SummaryPanelLocalPreferences {
version: 1;
show: boolean;
icon_scale: number;
font_scale: number;
}
export interface SummaryDraftProblem {
path: string;
kind: 'error' | 'warning';
code: 'required' | 'limit' | 'duplicate_id' | 'invalid_source' | 'missing_entity' | 'missing_space';
}
export interface SummaryLayoutInput {
width: number;
height: number;
safeLeft?: number;
safeRight?: number;
safeTop?: number;
safeBottom?: number;
controlTop?: number;
controlBottom?: number;
minimumHeight: number;
}
export interface SummaryLayout {
side: 'right' | 'bottom';
fits: boolean;
availableWidth: number;
heightCap: number;
top: number;
bottom: number;
}
const cpLength = (value: string): number => [...value].length;
const cleanText = (value: unknown): string => typeof value === 'string' ? value.trim() : '';
const own = (value: object, key: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(value, key);
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object';
export function createSummaryId(prefix: 'b' | 'v'): string {
const random = globalThis.crypto?.randomUUID?.()
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `${prefix}-${random}`;
}
export function defaultSummaryPanel(t: SummaryTranslate): SummaryPanelConfig {
return {
version: 1,
title: t('summary.default_title'),
show_on_mobile: true,
blocks: [{
id: 'summary-default',
title: t('summary.default_block'),
visible: true,
scope: { type: 'all' },
values: [
{ id: 'summary-device-count', label: t('summary.system.device_count'), source: { type: 'system', key: 'device_count' } },
{ id: 'summary-total-area', label: t('summary.system.total_area'), source: { type: 'system', key: 'total_area' } },
{ id: 'summary-datetime', label: t('summary.system.datetime'), source: { type: 'system', key: 'datetime' } },
],
}],
};
}
/** Undefined is a derived default; an unknown future version stays inert. */
export function summaryPanelOf(
settings: ServerConfig['settings'] | null | undefined,
t: SummaryTranslate,
): { config: SummaryPanelConfig | null; derived: boolean; unsupported: boolean } {
const raw = settings?.summary_panel;
if (raw === undefined) return { config: defaultSummaryPanel(t), derived: true, unsupported: false };
if (!runnableSummaryPanel(raw)) {
return { config: null, derived: false, unsupported: true };
}
return { config: raw, derived: false, unsupported: false };
}
function runnableSummaryPanel(raw: unknown): raw is SummaryPanelConfig {
if (!record(raw)) return false;
const panel = raw;
if (panel.version !== 1 || typeof panel.title !== 'string'
|| typeof panel.show_on_mobile !== 'boolean' || !Array.isArray(panel.blocks)) return false;
return panel.blocks.every((candidate) => {
if (!record(candidate) || typeof candidate.id !== 'string'
|| typeof candidate.title !== 'string' || typeof candidate.visible !== 'boolean'
|| !record(candidate.scope) || !Array.isArray(candidate.values)) return false;
const scope = candidate.scope;
if (scope.type !== 'all'
&& !(scope.type === 'space' && typeof scope.space_id === 'string')) return false;
return candidate.values.every((entry) => {
if (!record(entry) || typeof entry.id !== 'string' || typeof entry.label !== 'string'
|| !record(entry.source)) return false;
const source = entry.source;
return source.type === 'entity' && typeof source.entity_id === 'string'
|| source.type === 'system' && typeof source.key === 'string'
&& ['device_count', 'total_area', 'datetime'].includes(source.key);
});
});
}
export function cloneSummaryPanel(config: SummaryPanelConfig): SummaryPanelConfig {
return structuredClone(config);
}
export function visibleSummaryBlocks(
config: SummaryPanelConfig,
spaceId: string,
): SummaryPanelBlock[] {
return config.blocks.filter((block) => block.visible
&& (block.scope.type === 'all'
|| block.scope.type === 'space' && block.scope.space_id === spaceId));
}
function oldReferenceMaps(base: SummaryPanelConfig | null): {
scopes: Map<string, string>; sources: Map<string, string>;
} {
const scopes = new Map<string, string>();
const sources = new Map<string, string>();
for (const block of base?.blocks || []) {
if (block.scope.type === 'space') scopes.set(block.id, block.scope.space_id);
for (const value of block.values) {
if (value.source.type === 'entity') sources.set(value.id, value.source.entity_id);
}
}
return { scopes, sources };
}
export function validateSummaryDraft(
draft: SummaryPanelConfig,
base: SummaryPanelConfig | null,
spaceIds: ReadonlySet<string>,
entityIds: ReadonlySet<string>,
): SummaryDraftProblem[] {
const out: SummaryDraftProblem[] = [];
const refs = oldReferenceMaps(base);
const blockIds = new Set<string>();
const valueIds = new Set<string>();
const text = (value: unknown, max: number, path: string): void => {
const normalized = cleanText(value);
if (!normalized) out.push({ path, kind: 'error', code: 'required' });
else if (cpLength(normalized) > max) out.push({ path, kind: 'error', code: 'limit' });
};
text(draft.title, SUMMARY_PANEL_LIMITS.title, 'title');
if (draft.blocks.length > SUMMARY_PANEL_LIMITS.blocks) {
out.push({ path: 'blocks', kind: 'error', code: 'limit' });
}
draft.blocks.forEach((block, bi) => {
const bp = `blocks.${bi}`;
text(block.id, SUMMARY_PANEL_LIMITS.label, `${bp}.id`);
text(block.title, SUMMARY_PANEL_LIMITS.title, `${bp}.title`);
if (blockIds.has(block.id)) out.push({ path: `${bp}.id`, kind: 'error', code: 'duplicate_id' });
blockIds.add(block.id);
if (block.scope.type === 'space') {
const id = cleanText(block.scope.space_id);
if (!id) out.push({ path: `${bp}.scope`, kind: 'error', code: 'required' });
else if (!spaceIds.has(id)) out.push({
path: `${bp}.scope`,
kind: refs.scopes.get(block.id) === id ? 'warning' : 'error',
code: 'missing_space',
});
} else if (block.scope.type !== 'all') {
out.push({ path: `${bp}.scope`, kind: 'error', code: 'required' });
}
if (block.values.length > SUMMARY_PANEL_LIMITS.values) {
out.push({ path: `${bp}.values`, kind: 'error', code: 'limit' });
}
block.values.forEach((value, vi) => {
const vp = `${bp}.values.${vi}`;
text(value.id, SUMMARY_PANEL_LIMITS.label, `${vp}.id`);
text(value.label, SUMMARY_PANEL_LIMITS.label, `${vp}.label`);
if (valueIds.has(value.id)) out.push({ path: `${vp}.id`, kind: 'error', code: 'duplicate_id' });
valueIds.add(value.id);
if (value.source.type === 'entity') {
const id = cleanText(value.source.entity_id);
if (!id) out.push({ path: `${vp}.source`, kind: 'error', code: 'required' });
else if (!entityIds.has(id)) out.push({
path: `${vp}.source`,
kind: refs.sources.get(value.id) === id ? 'warning' : 'error',
code: 'missing_entity',
});
} else if (value.source.type !== 'system'
|| !['device_count', 'total_area', 'datetime'].includes(value.source.key)) {
out.push({ path: `${vp}.source`, kind: 'error', code: 'invalid_source' });
}
});
});
return out;
}
export function normalizeSummaryDraft(draft: SummaryPanelConfig): SummaryPanelConfig {
return {
...draft,
version: 1,
title: cleanText(draft.title),
show_on_mobile: draft.show_on_mobile !== false,
blocks: draft.blocks.map((block) => ({
...block,
id: cleanText(block.id),
title: cleanText(block.title),
visible: block.visible !== false,
scope: block.scope.type === 'space'
? { ...block.scope, type: 'space', space_id: cleanText(block.scope.space_id) }
: { ...block.scope, type: 'all' },
values: block.values.map((value) => ({
...value,
id: cleanText(value.id),
label: cleanText(value.label),
source: value.source.type === 'entity'
? { ...value.source, type: 'entity', entity_id: cleanText(value.source.entity_id) }
: { ...value.source, type: 'system', key: value.source.key },
})),
})),
};
}
export function resolveSummaryLayout(input: SummaryLayoutInput): SummaryLayout {
const width = Math.max(0, Number(input.width) || 0);
const height = Math.max(0, Number(input.height) || 0);
const side = width >= height ? 'right' : 'bottom';
const inset = 12;
const left = Math.max(0, input.safeLeft || 0) + inset;
const right = Math.max(0, input.safeRight || 0) + inset;
const top = Math.max(Math.max(0, input.safeTop || 0) + inset, input.controlTop || 0);
const bottom = Math.max(Math.max(0, input.safeBottom || 0) + inset, input.controlBottom || 0);
const availableWidth = Math.max(0, width - left - right);
const availableHeight = Math.max(0, height - top - bottom);
const heightCap = side === 'bottom' ? Math.min(height * 0.6, availableHeight) : availableHeight;
return {
side,
fits: width > 0 && height > 0 && availableWidth >= SUMMARY_PANEL_MIN_WIDTH
&& heightCap >= Math.max(0, input.minimumHeight),
availableWidth,
heightCap,
top,
bottom,
};
}
export function effectiveSummaryVisible(input: {
view: boolean; localShow: boolean; showOnMobile: boolean;
narrow: boolean | null; fits: boolean;
}): boolean {
const mobileAllowed = input.showOnMobile || input.narrow === false;
return input.view && input.localShow && mobileAllowed && input.fits;
}
export function summaryLocalKey(parts: {
userId?: string | null; path?: string; host?: string; slot?: string;
}): string | null {
const user = cleanText(parts.userId);
const slot = cleanText(parts.slot);
if (!user || !slot) return null;
const encoded = [user, cleanText(parts.path) || '/', cleanText(parts.host) || 'card', slot]
.map((value) => encodeURIComponent(value)).join(':');
return `${SUMMARY_PANEL_LOCAL_PREFIX}:${encoded}`;
}
export function normalizeSummaryScale(value: unknown): number {
const n = Number(value);
if (!Number.isFinite(n)) return 1;
return Math.max(0.5, Math.min(3, Math.round(n * 20) / 20));
}
export function parseSummaryLocal(
raw: unknown,
legacy: unknown,
): SummaryPanelLocalPreferences {
const source = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {};
const old = legacy && typeof legacy === 'object' ? legacy as Record<string, unknown> : {};
return {
version: 1,
show: source.show === true,
icon_scale: normalizeSummaryScale(own(source, 'icon_scale') ? source.icon_scale : old.icon),
font_scale: normalizeSummaryScale(own(source, 'font_scale') ? source.font_scale : old.font),
};
}
export function sameSummaryPanel(a: SummaryPanelConfig, b: SummaryPanelConfig): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function moveSummaryItem<T>(items: readonly T[], from: number, to: number): T[] {
if (from < 0 || from >= items.length || to < 0 || to >= items.length || from === to) return [...items];
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
export function newSummaryBlock(t: SummaryTranslate): SummaryPanelBlock {
return { id: createSummaryId('b'), title: t('summary.new_block'), visible: true, scope: { type: 'all' }, values: [] };
}
export function newSummaryValue(_t: SummaryTranslate): SummaryPanelValue {
return { id: createSummaryId('v'), label: '', source: { type: 'entity', entity_id: '' } };
}
+32
View File
@@ -256,9 +256,41 @@ export interface ServerConfig {
show_room_tooltip?: boolean;
/** Opt-in, admin-only contextual Zigbee diagnostics (#54). */
zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] };
/** Read-only grouped values shown over the plan (#437). */
summary_panel?: SummaryPanelConfig;
};
}
export type SummaryPanelSystemKey = 'device_count' | 'total_area' | 'datetime';
export type SummaryPanelSource =
| ({ type: 'entity'; entity_id: string } & Record<string, unknown>)
| ({ type: 'system'; key: SummaryPanelSystemKey } & Record<string, unknown>);
export type SummaryPanelScope =
| ({ type: 'all' } & Record<string, unknown>)
| ({ type: 'space'; space_id: string } & Record<string, unknown>);
export interface SummaryPanelValue extends Record<string, unknown> {
id: string;
label: string;
source: SummaryPanelSource;
}
export interface SummaryPanelBlock extends Record<string, unknown> {
id: string;
title: string;
visible: boolean;
scope: SummaryPanelScope;
values: SummaryPanelValue[];
}
export interface SummaryPanelConfig extends Record<string, unknown> {
version: 1;
title: string;
show_on_mobile: boolean;
blocks: SummaryPanelBlock[];
}
export type MarkerAreaBinding = `device:${string}` | `entity:${string}`;
export interface MarkerAreaSnapshotEntry {
+6 -1
View File
@@ -11,6 +11,7 @@ import {
type VersionRecoveryStorage,
} from './version-recovery';
import { DECOR_ASSETS_API_VERSION } from './decor-assets';
import { SUMMARY_PANEL_API_VERSION } from './summary-panel-api';
interface PendingCollection { readonly size: number }
interface PendingDebounce { pending(): boolean }
@@ -25,6 +26,7 @@ export interface ConfigCapabilitiesCardPort {
_haIntegrationVersion: string | null;
_haSupportApi: number | null;
_haDecorAssetsApi: number | null;
_haSummaryPanelApi: number | null;
_syncVersionRecovery(): void;
}
@@ -38,6 +40,7 @@ export interface AuthoritativeConfigResponse {
readonly integration_version?: unknown;
readonly support_api?: unknown;
readonly decor_assets_api?: unknown;
readonly summary_panel_api?: unknown;
readonly virtual_lights?: unknown;
}
@@ -47,7 +50,7 @@ export function adoptCardConfigCapabilities(
): void {
const capabilities = response && typeof response === 'object'
? response as Partial<Record<
'integration_version' | 'support_api' | 'decor_assets_api', unknown
'integration_version' | 'support_api' | 'decor_assets_api' | 'summary_panel_api', unknown
>> : {};
host._haIntegrationVersion = normalizeRuntimeVersion(capabilities.integration_version);
const supportApi = capabilities.support_api;
@@ -55,6 +58,8 @@ export function adoptCardConfigCapabilities(
? supportApi : null;
host._haDecorAssetsApi = capabilities.decor_assets_api === DECOR_ASSETS_API_VERSION
? DECOR_ASSETS_API_VERSION : null;
host._haSummaryPanelApi = capabilities.summary_panel_api === SUMMARY_PANEL_API_VERSION
? SUMMARY_PANEL_API_VERSION : null;
host._syncVersionRecovery();
}