mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
feat: add configurable summary panel (#437)
Issue: #437 User-Visible: yes
This commit is contained in:
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Backend capability discriminator for the shared summary-panel schema. */
|
||||
export const SUMMARY_PANEL_API_VERSION = 1;
|
||||
@@ -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>`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 || ''));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%; }
|
||||
}`;
|
||||
@@ -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: '' } };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user