Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld f075d544b8 Share one base class across the home summary panels
The maintenance, climate and security panels carried the same ~300 line shell: config loading with revision guards, queued saves with failure recovery, the edit visibility toggle and view generation. Move it into HaHomeSummaryPanelBase, keyed on the system data key, which doubles as the strategy type, URL path and translation namespace. Security only adds its strategy options and the favorite and alert handlers, which the base binds as optional hooks.

The view container now takes min-height: 100% of the top app bar's scroll area. Climate still had min-height: 100vh from before its top bar migration, which overflowed the scroll area by the header height, while security and maintenance had dropped it and cut themed backgrounds off below short content.

The home panel subscribed to and loaded the three configs in three copies of the same block. Keep them in one record keyed the same way and loop over the keys.
2026-11-05 16:42:36 +01:00
6 changed files with 409 additions and 1052 deletions

No files matched your search

+3
View File
@@ -60,6 +60,9 @@ export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export type HomeSummaryPanelSystemDataKey =
"security" | "maintenance" | "climate";
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
+4 -308
View File
@@ -1,313 +1,9 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type ClimateFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-climate")
class PanelClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _config?: ClimateFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"climate"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.climate.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "climate",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"climate",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelClimate extends HaHomeSummaryPanelBase<"climate"> {
protected readonly _systemDataKey = "climate";
}
declare global {
@@ -0,0 +1,324 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
export abstract class HaHomeSummaryPanelBase<
K extends HomeSummaryPanelSystemDataKey,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() protected _config?: FrontendSystemData[K];
@state() private _lovelace?: Lovelace;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
protected abstract readonly _systemDataKey: K;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
protected _strategyOptions(): Record<string, unknown> {
return {};
}
protected _favoriteChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
): void;
protected _alertChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
): void;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (
oldHass &&
(oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels) &&
this.hass.config.state === "RUNNING"
) {
this._debounceRegistriesChanged();
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
this._systemDataKey
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || ({} as FrontendSystemData[K]);
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
this._editVisibility = false;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.load_failed`),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(() => this._setLovelace(), 200);
protected render() {
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize(`panel.${key}`)}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize(`ui.panel.${key}.editor.title`)}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${0}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
protected async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const strategy = { type: this._systemDataKey };
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...strategy,
...this._strategyOptions(),
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [{ strategy }] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: this._systemDataKey,
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
protected _saveConfig(config: FrontendSystemData[K]): Promise<void> {
this._config = config;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return queueFrontendSystemDataSave(
this.hass.connection,
this._systemDataKey,
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error(`Failed to save ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.save_failed`),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100%;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
+61 -114
View File
@@ -16,10 +16,8 @@ import {
fetchFrontendSystemData,
saveFrontendSystemData,
subscribeFrontendSystemData,
type ClimateFrontendSystemData,
type HomeFrontendSystemData,
type MaintenanceFrontendSystemData,
type SecurityFrontendSystemData,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
@@ -40,6 +38,16 @@ import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
import { hasLegacyOverviewPanel } from "../../data/panel";
const SUMMARY_PANEL_KEYS: HomeSummaryPanelSystemDataKey[] = [
"security",
"maintenance",
"climate",
];
type SummaryPanelConfigs = {
[K in HomeSummaryPanelSystemDataKey]: FrontendSystemData[K];
};
@customElement("ha-panel-home")
class PanelHome extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,11 +62,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _config?: HomeFrontendSystemData;
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
@state() private _climateConfig: ClimateFrontendSystemData = {};
@state() private _summaryConfigs: SummaryPanelConfigs = {
security: {},
maintenance: {},
climate: {},
};
@state() private _extraActionItems?: ExtraActionItem[];
@@ -66,60 +74,29 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _loadConfigPromise?: Promise<void>;
private _securityConfigRevision = 0;
private _maintenanceConfigRevision = 0;
private _climateConfigRevision = 0;
private _summaryConfigRevisions: Record<
HomeSummaryPanelSystemDataKey,
number
> = {
security: 0,
maintenance: 0,
climate: 0,
};
public hassSubscribe() {
return [
subscribeFrontendSystemData(
this.hass.connection,
"security",
({ value }) => {
this._securityConfigRevision++;
const config = value || {};
if (deepEqual(config, this._securityConfig)) {
return;
}
this._securityConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
return SUMMARY_PANEL_KEYS.map((key) =>
subscribeFrontendSystemData(this.hass.connection, key, ({ value }) => {
this._summaryConfigRevisions[key]++;
const config = value || {};
if (deepEqual(config, this._summaryConfigs[key])) {
return;
}
),
subscribeFrontendSystemData(
this.hass.connection,
"maintenance",
({ value }) => {
this._maintenanceConfigRevision++;
const config = value || {};
if (deepEqual(config, this._maintenanceConfig)) {
return;
}
this._maintenanceConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
this._summaryConfigs = { ...this._summaryConfigs, [key]: config };
if (this.hasUpdated) {
this._setLovelace();
}
),
subscribeFrontendSystemData(
this.hass.connection,
"climate",
({ value }) => {
this._climateConfigRevision++;
const config = value || {};
if (deepEqual(config, this._climateConfig)) {
return;
}
this._climateConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
];
})
);
}
private get _showBanner(): boolean {
@@ -218,22 +195,15 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
const maintenanceConfigRevision = this._maintenanceConfigRevision;
const climateConfigRevision = this._climateConfigRevision;
const [
translationsResult,
homeResult,
securityResult,
maintenanceResult,
climateResult,
] = await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
fetchFrontendSystemData(this.hass.connection, "maintenance"),
fetchFrontendSystemData(this.hass.connection, "climate"),
]);
const summaryRevisions = { ...this._summaryConfigRevisions };
const [translationsResult, homeResult, ...summaryResults] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
...SUMMARY_PANEL_KEYS.map((key) =>
fetchFrontendSystemData(this.hass.connection, key)
),
]);
if (translationsResult.status === "rejected") {
// eslint-disable-next-line no-console
@@ -251,44 +221,20 @@ class PanelHome extends SubscribeMixin(LitElement) {
this._config = homeResult.value || {};
}
if (securityResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load security configuration:",
securityResult.reason
);
if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = {};
const summaryConfigs = { ...this._summaryConfigs };
SUMMARY_PANEL_KEYS.forEach((key, index) => {
if (summaryRevisions[key] !== this._summaryConfigRevisions[key]) {
return;
}
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
if (maintenanceResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load maintenance configuration:",
maintenanceResult.reason
);
if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = {};
const result = summaryResults[index];
if (result.status === "rejected") {
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, result.reason);
}
} else if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = maintenanceResult.value || {};
}
if (climateResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load climate configuration:",
climateResult.reason
);
if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = {};
}
} else if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = climateResult.value || {};
}
summaryConfigs[key] =
result.status === "fulfilled" && result.value ? result.value : {};
});
this._summaryConfigs = summaryConfigs;
}
private _debounceRegenerateStrategy = debounce(
@@ -474,10 +420,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
return {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
security_hidden_entities: this._securityConfig.hidden_entities,
maintenance_hidden_entities: this._maintenanceConfig.hidden_entities,
climate_hidden_entities: this._climateConfig.hidden_entities,
alert_entities: this._summaryConfigs.security.alert_entities,
security_hidden_entities: this._summaryConfigs.security.hidden_entities,
maintenance_hidden_entities:
this._summaryConfigs.maintenance.hidden_entities,
climate_hidden_entities: this._summaryConfigs.climate.hidden_entities,
favorite_entities: this._config?.favorite_entities,
home_panel: true,
hide_welcome_message: this._config?.hide_welcome_message,
+4 -308
View File
@@ -1,313 +1,9 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type MaintenanceFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
const MAINTENANCE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "maintenance",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-maintenance")
class PanelMaintenance extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _config?: MaintenanceFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"maintenance"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
this._editVisibility = false;
// eslint-disable-next-line no-console
console.error("Failed to load maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.maintenance.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...MAINTENANCE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "maintenance",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: MaintenanceFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"maintenance",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelMaintenance extends HaHomeSummaryPanelBase<"maintenance"> {
protected readonly _systemDataKey = "maintenance";
}
declare global {
+13 -322
View File
@@ -1,288 +1,21 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "security",
},
};
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
class PanelSecurity extends HaHomeSummaryPanelBase<"security"> {
protected readonly _systemDataKey = "security";
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "security",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
protected _strategyOptions() {
return {
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
};
}
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _favoriteChanged = (
protected _favoriteChanged(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
) => {
) {
if (!this._config) {
return;
}
@@ -298,11 +31,11 @@ class PanelSecurity extends LitElement {
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
});
this._setLovelace();
};
}
private _alertChanged = (
protected _alertChanged(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
) => {
) {
if (!this._config) {
return;
}
@@ -322,48 +55,6 @@ class PanelSecurity extends LitElement {
alert_entities: alertEntities.length ? alertEntities : undefined,
});
this._setLovelace();
};
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"security",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}